Mbonea Claude Sonnet 4.6 commited on
Commit
fe91d40
·
1 Parent(s): a4ca9d1

Add UI agent briefing with params and code snippets for all endpoints

Browse files
Files changed (1) hide show
  1. UI_AGENT_BRIEFING.md +673 -0
UI_AGENT_BRIEFING.md ADDED
@@ -0,0 +1,673 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UI Agent Briefing — WiFi Platform Frontend
2
+
3
+ > Read `API_DOCUMENTATION.md` and `FRONTEND_DESIGN_SYSTEM.md` in full before writing any code.
4
+ > This document is a focused briefing of what to build, with params and code snippets for every feature.
5
+
6
+ ---
7
+
8
+ ## Stack Assumptions
9
+
10
+ - Framework: React (or Vue/Svelte — adapt snippets accordingly)
11
+ - HTTP: `fetch` or `axios`
12
+ - Icons: Lucide only (`lucide-react` / `lucide-vue`)
13
+ - No emojis anywhere — ever
14
+ - All auth requests send `Authorization: Bearer <token>` header
15
+
16
+ ```js
17
+ // api/client.js — base helper used in all snippets below
18
+ const BASE = import.meta.env.VITE_API_URL || '';
19
+
20
+ export async function api(path, options = {}) {
21
+ const token = localStorage.getItem('token');
22
+ const res = await fetch(`${BASE}${path}`, {
23
+ ...options,
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
27
+ ...options.headers,
28
+ },
29
+ });
30
+ const data = await res.json();
31
+ if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status });
32
+ return data;
33
+ }
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 1. Authentication
39
+
40
+ ### POST `/api/auth/register`
41
+
42
+ **Params (body):**
43
+ | Field | Type | Required |
44
+ |---|---|---|
45
+ | `business_name` | string | yes |
46
+ | `contact_name` | string | yes |
47
+ | `email` | string | yes |
48
+ | `phone` | string | yes |
49
+ | `password` | string min 8 | yes |
50
+
51
+ ```js
52
+ const { token, client } = await api('/api/auth/register', {
53
+ method: 'POST',
54
+ body: JSON.stringify({ business_name, contact_name, email, phone, password }),
55
+ });
56
+ localStorage.setItem('token', token);
57
+ ```
58
+
59
+ ---
60
+
61
+ ### POST `/api/auth/login`
62
+
63
+ **Params (body):**
64
+ | Field | Type | Required |
65
+ |---|---|---|
66
+ | `email` | string | yes |
67
+ | `password` | string | yes |
68
+
69
+ ```js
70
+ const { token, client } = await api('/api/auth/login', {
71
+ method: 'POST',
72
+ body: JSON.stringify({ email, password }),
73
+ });
74
+ localStorage.setItem('token', token);
75
+ ```
76
+
77
+ ---
78
+
79
+ ### POST `/api/auth/forgot-password`
80
+
81
+ **Params (body):**
82
+ | Field | Type | Required |
83
+ |---|---|---|
84
+ | `phone` | string | yes |
85
+
86
+ ```js
87
+ await api('/api/auth/forgot-password', {
88
+ method: 'POST',
89
+ body: JSON.stringify({ phone }),
90
+ });
91
+ // Always returns 200 — don't reveal whether phone is registered
92
+ ```
93
+
94
+ ---
95
+
96
+ ### POST `/api/auth/reset-password`
97
+
98
+ **Params (body):**
99
+ | Field | Type | Required |
100
+ |---|---|---|
101
+ | `phone` | string | yes |
102
+ | `otp` | string 6-digit | yes |
103
+ | `new_password` | string min 8 | yes |
104
+
105
+ ```js
106
+ await api('/api/auth/reset-password', {
107
+ method: 'POST',
108
+ body: JSON.stringify({ phone, otp, new_password }),
109
+ });
110
+ ```
111
+
112
+ ---
113
+
114
+ ### GET `/api/auth/me`
115
+
116
+ No params. Returns the authenticated tenant's full profile. Use this to seed the sidebar business name and profile page.
117
+
118
+ ```js
119
+ const client = await api('/api/auth/me');
120
+ // { id, business_name, contact_name, email, phone,
121
+ // balance, total_earned, total_withdrawn, created_at }
122
+ ```
123
+
124
+ ---
125
+
126
+ ### PATCH `/api/auth/profile`
127
+
128
+ Send only the fields you want to change.
129
+
130
+ **Params (body — all optional, send at least one):**
131
+ | Field | Type |
132
+ |---|---|
133
+ | `contact_name` | string |
134
+ | `business_name` | string |
135
+ | `email` | string |
136
+
137
+ ```js
138
+ const updated = await api('/api/auth/profile', {
139
+ method: 'PATCH',
140
+ body: JSON.stringify({ contact_name, business_name, email }),
141
+ });
142
+ // Returns updated client object (same shape as /me)
143
+ ```
144
+
145
+ ---
146
+
147
+ ### POST `/api/auth/change-password`
148
+
149
+ **Params (body):**
150
+ | Field | Type | Required |
151
+ |---|---|---|
152
+ | `current_password` | string | yes |
153
+ | `new_password` | string min 8 | yes |
154
+
155
+ ```js
156
+ await api('/api/auth/change-password', {
157
+ method: 'POST',
158
+ body: JSON.stringify({ current_password, new_password }),
159
+ });
160
+ // 401 if current_password is wrong
161
+ ```
162
+
163
+ ---
164
+
165
+ ### POST `/api/auth/phone/request`
166
+
167
+ Sends OTP to the new phone number. Rate limited — 3 requests per 30 min.
168
+
169
+ **Params (body):**
170
+ | Field | Type | Required |
171
+ |---|---|---|
172
+ | `new_phone` | string | yes |
173
+
174
+ ```js
175
+ await api('/api/auth/phone/request', {
176
+ method: 'POST',
177
+ body: JSON.stringify({ new_phone }),
178
+ });
179
+ // Show OTP input after this succeeds
180
+ ```
181
+
182
+ ---
183
+
184
+ ### POST `/api/auth/phone/confirm`
185
+
186
+ **Params (body):**
187
+ | Field | Type | Required |
188
+ |---|---|---|
189
+ | `new_phone` | string | yes |
190
+ | `otp` | string 6-digit | yes |
191
+
192
+ ```js
193
+ await api('/api/auth/phone/confirm', {
194
+ method: 'POST',
195
+ body: JSON.stringify({ new_phone, otp }),
196
+ });
197
+ // On success, refresh /me to show updated phone
198
+ ```
199
+
200
+ ---
201
+
202
+ ## 2. Devices
203
+
204
+ ### GET `/api/devices`
205
+
206
+ No params. Returns array of tenant's devices.
207
+
208
+ ```js
209
+ const devices = await api('/api/devices');
210
+ // [{ id, name, location, mac, model, ip, status, billing_status,
211
+ // billing_expires_at, monthly_fee, guest_count, last_seen_at, ... }]
212
+ ```
213
+
214
+ Show a warning chip on rows where `billing_expires_at` is within 3 days.
215
+
216
+ ---
217
+
218
+ ### POST `/api/devices`
219
+
220
+ **Params (body):**
221
+ | Field | Type | Required |
222
+ |---|---|---|
223
+ | `name` | string | yes |
224
+ | `mac` | string AA-BB-CC-DD-EE-FF | yes |
225
+ | `location` | string | no |
226
+ | `device_username` | string | no |
227
+ | `device_password` | string | no |
228
+
229
+ ```js
230
+ const { device, message } = await api('/api/devices', {
231
+ method: 'POST',
232
+ body: JSON.stringify({ name, mac, location, device_username, device_password }),
233
+ });
234
+ ```
235
+
236
+ ---
237
+
238
+ ### GET `/api/devices/:id`
239
+
240
+ ```js
241
+ const device = await api(`/api/devices/${id}`);
242
+ // Includes active_sessions array, sales_today, earned_today
243
+ ```
244
+
245
+ ---
246
+
247
+ ### DELETE `/api/devices/:id`
248
+
249
+ Pauses the device (reversible). Show confirmation modal: "This will kick all connected guests and suspend WiFi."
250
+
251
+ ```js
252
+ await api(`/api/devices/${id}`, { method: 'DELETE' });
253
+ ```
254
+
255
+ ---
256
+
257
+ ### POST `/api/devices/:id/renew`
258
+
259
+ Initiates M-Pesa renewal payment to tenant's phone.
260
+
261
+ ```js
262
+ const { reference, amount } = await api(`/api/devices/${id}/renew`, { method: 'POST' });
263
+ // Show: "Check your phone for M-Pesa prompt"
264
+ // Poll GET /api/devices/:id until billing_status = 'active'
265
+ ```
266
+
267
+ ---
268
+
269
+ ### GET `/api/devices/:id/portal`
270
+
271
+ ```js
272
+ const settings = await api(`/api/devices/${id}/portal`);
273
+ // { device_id, display_name, welcome_text, logo_url,
274
+ // support_phone, primary_color, bg_color }
275
+ ```
276
+
277
+ ---
278
+
279
+ ### PATCH `/api/devices/:id/portal`
280
+
281
+ All fields optional — send only what changed.
282
+
283
+ **Params (body):**
284
+ | Field | Type | Validation |
285
+ |---|---|---|
286
+ | `display_name` | string | — |
287
+ | `welcome_text` | string | — |
288
+ | `logo_url` | string | — |
289
+ | `support_phone` | string | — |
290
+ | `primary_color` | string | must be `#RRGGBB` |
291
+ | `bg_color` | string | must be `#RRGGBB` |
292
+
293
+ ```js
294
+ const updated = await api(`/api/devices/${id}/portal`, {
295
+ method: 'PATCH',
296
+ body: JSON.stringify({ display_name, welcome_text, logo_url, support_phone, primary_color, bg_color }),
297
+ });
298
+ ```
299
+
300
+ Show a live preview panel next to this form. Render a scaled-down version of the portal card using the form values as CSS variables `--primary` and `--bg`.
301
+
302
+ ---
303
+
304
+ ## 3. WiFi Plans
305
+
306
+ ### GET `/api/plans?deviceId=:id`
307
+
308
+ **Params (query):**
309
+ | Param | Required |
310
+ |---|---|
311
+ | `deviceId` | yes |
312
+
313
+ ```js
314
+ const plans = await api(`/api/plans?deviceId=${deviceId}`);
315
+ // [{ id, name, duration_seconds, price, down_limit, down_unit,
316
+ // up_limit, up_unit, is_active, display_order }]
317
+ ```
318
+
319
+ ---
320
+
321
+ ### POST `/api/plans`
322
+
323
+ **Params (body):**
324
+ | Field | Type | Required |
325
+ |---|---|---|
326
+ | `device_id` | number | yes |
327
+ | `name` | string | yes |
328
+ | `duration_seconds` | number | yes |
329
+ | `price` | number TZS | yes |
330
+ | `down_limit` | number | yes |
331
+ | `down_unit` | 1=Kbps / 2=Mbps | yes |
332
+ | `up_limit` | number | yes |
333
+ | `up_unit` | 1=Kbps / 2=Mbps | yes |
334
+ | `display_order` | number | no |
335
+
336
+ ```js
337
+ const plan = await api('/api/plans', {
338
+ method: 'POST',
339
+ body: JSON.stringify({ device_id, name, duration_seconds, price,
340
+ down_limit, down_unit, up_limit, up_unit, display_order }),
341
+ });
342
+ ```
343
+
344
+ Show a duration field with a unit selector (minutes / hours / days) and convert to `duration_seconds` before submitting:
345
+ ```js
346
+ const duration_seconds = value * { minutes: 60, hours: 3600, days: 86400 }[unit];
347
+ ```
348
+
349
+ ---
350
+
351
+ ### PUT `/api/plans/:id`
352
+
353
+ Same body as POST, all fields optional.
354
+
355
+ ```js
356
+ await api(`/api/plans/${id}`, {
357
+ method: 'PUT',
358
+ body: JSON.stringify({ name, price, is_active }),
359
+ });
360
+ ```
361
+
362
+ ---
363
+
364
+ ### DELETE `/api/plans/:id`
365
+
366
+ ```js
367
+ await api(`/api/plans/${id}`, { method: 'DELETE' });
368
+ ```
369
+
370
+ ---
371
+
372
+ ## 4. Dashboard
373
+
374
+ ### GET `/api/dashboard/summary`
375
+
376
+ ```js
377
+ const summary = await api('/api/dashboard/summary');
378
+ // { device_count, active_sessions, revenue_today,
379
+ // revenue_month, balance, alert_count }
380
+ ```
381
+
382
+ Show alert warning banner if `alert_count > 0`.
383
+
384
+ ---
385
+
386
+ ### GET `/api/dashboard/revenue/weekly`
387
+
388
+ ```js
389
+ const weekly = await api('/api/dashboard/revenue/weekly');
390
+ // [{ day: 'Mon', revenue: 45000 }, ...]
391
+ ```
392
+
393
+ Feed into a bar chart. X-axis = day label, Y-axis = TZS (format with `.toLocaleString()`). Fill missing days with 0.
394
+
395
+ ---
396
+
397
+ ## 5. Sessions
398
+
399
+ ### GET `/api/sessions`
400
+
401
+ **Params (query — all optional):**
402
+ | Param | Type | Description |
403
+ |---|---|---|
404
+ | `deviceId` | number | Filter by device |
405
+ | `active` | boolean | `true` for active only |
406
+ | `page` | number | Pagination |
407
+ | `limit` | number | Default 50 |
408
+
409
+ ```js
410
+ const sessions = await api(`/api/sessions?deviceId=${id}&active=true`);
411
+ // [{ id, client_mac, started_at, ended_at, bytes_down, bytes_up,
412
+ // is_active, plan_name, expires_at }]
413
+ ```
414
+
415
+ Format bytes:
416
+ ```js
417
+ function formatBytes(bytes) {
418
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
419
+ if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB';
420
+ return (bytes / 1024 ** 3).toFixed(2) + ' GB';
421
+ }
422
+ ```
423
+
424
+ ---
425
+
426
+ ## 6. Payouts
427
+
428
+ ### GET `/api/payouts`
429
+
430
+ ```js
431
+ const payouts = await api('/api/payouts');
432
+ // [{ id, amount, payout_fee, net_amount, status,
433
+ // destination_phone, created_at, completed_at }]
434
+ ```
435
+
436
+ ---
437
+
438
+ ### POST `/api/payouts`
439
+
440
+ **Params (body):**
441
+ | Field | Type | Required |
442
+ |---|---|---|
443
+ | `amount` | number TZS | yes |
444
+
445
+ ```js
446
+ const payout = await api('/api/payouts', {
447
+ method: 'POST',
448
+ body: JSON.stringify({ amount }),
449
+ });
450
+ ```
451
+
452
+ Show `net_amount` preview before confirming:
453
+ ```js
454
+ const SNIPPE_FEE_RATE = 0.005;
455
+ const net = amount * (1 - SNIPPE_FEE_RATE);
456
+ // "You will receive X TZS after fees"
457
+ ```
458
+
459
+ Disable submit if `balance === 0`.
460
+
461
+ ---
462
+
463
+ ## 7. Alerts
464
+
465
+ ### GET `/api/alerts/unresolved`
466
+
467
+ ```js
468
+ const alerts = await api('/api/alerts/unresolved');
469
+ // [{ id, severity, message, device_name, created_at }]
470
+ ```
471
+
472
+ `severity` values: `info`, `warning`, `critical` — map to `--color-info`, `--color-warning`, `--color-error`.
473
+
474
+ ---
475
+
476
+ ### PATCH `/api/alerts/:id/resolve`
477
+
478
+ ```js
479
+ await api(`/api/alerts/${id}/resolve`, { method: 'PATCH' });
480
+ // Remove row with fade-out animation
481
+ ```
482
+
483
+ ---
484
+
485
+ ## 8. Super Admin
486
+
487
+ All admin endpoints require `Authorization: Bearer <ADMIN_TOKEN>` (static token, not a JWT).
488
+
489
+ ```js
490
+ function adminApi(path, options = {}) {
491
+ return api(path, {
492
+ ...options,
493
+ headers: {
494
+ Authorization: `Bearer ${import.meta.env.VITE_ADMIN_TOKEN}`,
495
+ ...options.headers,
496
+ },
497
+ });
498
+ }
499
+ ```
500
+
501
+ ---
502
+
503
+ ### GET `/api/admin/overview`
504
+
505
+ ```js
506
+ const stats = await adminApi('/api/admin/overview');
507
+ // { tenants, devices_online, devices_offline, devices_suspended,
508
+ // guests_online, sales_today, gross_today, commission_today,
509
+ // pending_payouts, pending_payout_total }
510
+ ```
511
+
512
+ ---
513
+
514
+ ### GET `/api/admin/tenants`
515
+
516
+ ```js
517
+ const tenants = await adminApi('/api/admin/tenants');
518
+ // [{ id, business_name, contact_name, phone, balance,
519
+ // device_count, devices_online, created_at }]
520
+ ```
521
+
522
+ ---
523
+
524
+ ### GET `/api/admin/devices`
525
+
526
+ ```js
527
+ const devices = await adminApi('/api/admin/devices');
528
+ // [{ id, name, location, status, billing_status,
529
+ // billing_expires_at, monthly_fee, business_name, client_phone }]
530
+ ```
531
+
532
+ ---
533
+
534
+ ### POST `/api/admin/devices/:id/activate`
535
+
536
+ Cash payment activation. Opens a "Record Cash Payment" modal with device name, tenant name, fee amount, and an optional note field.
537
+
538
+ **Params (body):**
539
+ | Field | Type | Required |
540
+ |---|---|---|
541
+ | `note` | string | no |
542
+
543
+ ```js
544
+ const result = await adminApi(`/api/admin/devices/${id}/activate`, {
545
+ method: 'POST',
546
+ body: JSON.stringify({ note }),
547
+ });
548
+ // { message, billing_expires_at }
549
+ // On success: toast "Device activated — tenant notified by SMS"
550
+ // Update row billing_status to 'active' optimistically
551
+ ```
552
+
553
+ ---
554
+
555
+ ### POST `/api/admin/devices/:id/reboot`
556
+
557
+ ```js
558
+ await adminApi(`/api/admin/devices/${id}/reboot`, { method: 'POST' });
559
+ ```
560
+
561
+ ---
562
+
563
+ ### GET `/api/admin/payouts/pending`
564
+
565
+ ```js
566
+ const payouts = await adminApi('/api/admin/payouts/pending');
567
+ // [{ id, amount, net_amount, destination_phone,
568
+ // business_name, contact_name, created_at }]
569
+ ```
570
+
571
+ ---
572
+
573
+ ### POST `/api/admin/payouts/:id/approve`
574
+
575
+ ```js
576
+ await adminApi(`/api/admin/payouts/${id}/approve`, { method: 'POST' });
577
+ ```
578
+
579
+ ---
580
+
581
+ ### POST `/api/admin/payouts/:id/reject`
582
+
583
+ ```js
584
+ await adminApi(`/api/admin/payouts/${id}/reject`, { method: 'POST' });
585
+ ```
586
+
587
+ ---
588
+
589
+ ### GET `/api/admin/revenue/daily`
590
+
591
+ **Params (query):**
592
+ | Param | Type | Default |
593
+ |---|---|---|
594
+ | `days` | number | 30 |
595
+
596
+ ```js
597
+ const revenue = await adminApi('/api/admin/revenue/daily?days=30');
598
+ // [{ day: '2026-04-15', sales: 12, gross: 180000, commission: 9000 }]
599
+ ```
600
+
601
+ Render as a line chart. Days selector: 7 / 14 / 30 / 90.
602
+
603
+ ---
604
+
605
+ ## 9. Shared UX Patterns
606
+
607
+ ### Status badge mapping
608
+
609
+ ```js
610
+ const STATUS_BADGE = {
611
+ active: { bg: 'var(--color-success-bg)', text: 'var(--color-success)' },
612
+ online: { bg: 'var(--color-success-bg)', text: 'var(--color-success)' },
613
+ trial: { bg: 'var(--color-info-bg)', text: 'var(--color-info)' },
614
+ suspended: { bg: 'var(--color-warning-bg)', text: 'var(--color-warning)' },
615
+ offline: { bg: 'var(--color-bg)', text: 'var(--color-text-disabled)' },
616
+ pending: { bg: 'var(--color-warning-bg)', text: 'var(--color-warning)' },
617
+ failed: { bg: 'var(--color-error-bg)', text: 'var(--color-error)' },
618
+ completed: { bg: 'var(--color-success-bg)', text: 'var(--color-success)' },
619
+ };
620
+ ```
621
+
622
+ ### Duration formatting
623
+
624
+ ```js
625
+ function formatDuration(seconds) {
626
+ if (seconds >= 86400) {
627
+ const d = Math.floor(seconds / 86400);
628
+ return `${d} Day${d !== 1 ? 's' : ''}`;
629
+ }
630
+ const h = Math.floor(seconds / 3600);
631
+ return `${h} Hour${h !== 1 ? 's' : ''}`;
632
+ }
633
+ ```
634
+
635
+ ### Speed formatting
636
+
637
+ ```js
638
+ function formatSpeed(limit, unit) {
639
+ return `${limit}${unit === 2 ? 'Mbps' : 'Kbps'}`;
640
+ }
641
+ ```
642
+
643
+ ### Currency formatting
644
+
645
+ ```js
646
+ function formatTZS(amount) {
647
+ return `${parseInt(amount).toLocaleString()} TZS`;
648
+ }
649
+ ```
650
+
651
+ ### Time remaining
652
+
653
+ ```js
654
+ function formatTimeLeft(expiresAt) {
655
+ const secs = Math.floor((new Date(expiresAt) - Date.now()) / 1000);
656
+ if (secs <= 0) return 'Expired';
657
+ if (secs < 3600) return `${Math.ceil(secs / 60)}min`;
658
+ if (secs < 86400) return `${Math.ceil(secs / 3600)}h`;
659
+ return `${Math.ceil(secs / 86400)}d`;
660
+ }
661
+ ```
662
+
663
+ ### Error handling
664
+
665
+ ```js
666
+ try {
667
+ await someApiCall();
668
+ } catch (err) {
669
+ if (err.status === 401) { /* redirect to login */ }
670
+ if (err.status === 409) { /* show field-level error */ }
671
+ showToast('error', err.message);
672
+ }
673
+ ```