Skip to content

Commit 46ad6db

Browse files
committed
Merge remote-tracking branch 'upstream/main' into feature/fe-payment-success-settings-dashboard-1179-1182
# Conflicts: # frontend/messages/en.json # frontend/messages/es.json # frontend/messages/pt.json # frontend/src/app/(authenticated)/settings/SettingsDashboardClient.test.tsx # frontend/src/app/(authenticated)/settings/page.tsx
2 parents fbd9f1b + cf65311 commit 46ad6db

23 files changed

Lines changed: 736 additions & 524 deletions

.gitignore

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
node_modules/
66
npm-debug.log*
77
yarn-debug.log*
8+
yarn-debug.log*
89
yarn-error.log*
910
pnpm-debug.log*
1011

@@ -39,6 +40,34 @@ resources.md
3940
x402_stellar_guide.md
4041
prompt.md
4142
backend_check_fail.md
42-
frontend_check_fail.md
43+
frontend_check_fail.md
4344
issue.md
44-
pr.md
45+
pr.md
46+
47+
# ── Test artifacts ────────────────────────────────────────────────
48+
# Playwright / Vitest output directories
49+
test-results/
50+
**/test-results/
51+
playwright-report/
52+
**/playwright-report/
53+
54+
# Visual regression snapshots generated at runtime
55+
**/*.spec.ts-snapshots/
56+
**/*.spec.tsx-snapshots/
57+
**/e2e/*.spec.ts-snapshots/
58+
**/e2e/*.spec.tsx-snapshots/
59+
60+
# ── Build / compile artifacts ─────────────────────────────────────
61+
# TypeScript incremental build cache — no value in version control
62+
tsconfig.tsbuildinfo
63+
**/tsconfig.tsbuildinfo
64+
65+
# ── Misc output files ─────────────────────────────────────────────
66+
output.txt
67+
**/output.txt
68+
verify_output.txt
69+
**/verify_output.txt
70+
71+
# ── Lock files (keep pnpm-lock.yaml, ignore others) ───────────────
72+
# Root-level package-lock from accidental npm installs
73+
package-lock.json

frontend/.gitignore

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,21 @@
11
node_modules/
2-
package-lock.json
2+
package-lock.json
3+
4+
# Next.js build output
5+
.next/
6+
out/
7+
8+
# TypeScript incremental build cache
9+
tsconfig.tsbuildinfo
10+
11+
# Playwright test artifacts
12+
test-results/
13+
playwright-report/
14+
15+
# Visual regression snapshots (generated at runtime, not committed)
16+
tests/e2e/*.spec.ts-snapshots/
17+
tests/e2e/*.spec.tsx-snapshots/
18+
*-snapshots/
19+
20+
# Misc output files generated by scripts/debug runs
21+
output.txt
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# User Permissions Manager Bundle Optimization
2+
3+
## Summary
4+
5+
Issue #1174 reduces the client-side cost of the User Permissions Manager while keeping the same accessible controls and visual feedback.
6+
7+
## Changes
8+
9+
- Removed the direct `framer-motion` dependency from `UserPermissionsManager`.
10+
- Replaced row, toggle, and category affordance animations with Tailwind/CSS transitions.
11+
- Preserved `motion-reduce` behavior for users who prefer reduced motion.
12+
- Lazy-loaded the permissions manager from the Settings permissions tab with `next/dynamic`.
13+
- Added a tabpanel wrapper around the settings permissions view for consistent screen-reader navigation.
14+
15+
## Accessibility
16+
17+
- Permission switches remain native checkboxes with stable `aria-label` values.
18+
- Category groups keep `aria-expanded`, `aria-controls`, and region labels.
19+
- Pending permission writes continue to set `aria-busy` on the manager region.
20+
- Read-only mode disables controls and keeps the read-only notice visible.
21+
22+
## Verification
23+
24+
Run:
25+
26+
```bash
27+
npm run test:unit -- UserPermissionsManager.test.tsx
28+
npx eslint src/components/UserPermissionsManager.tsx "src/app/(authenticated)/settings/page.tsx"
29+
```
30+
31+
Full build verification may still be blocked by unrelated syntax errors in other frontend files and by `next/font` network access in restricted environments.

frontend/messages/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,17 @@
440440
"failedToTestWebhook": "Failed to test webhook",
441441
"testWebhookSentStatus": "Test webhook sent — status {status}",
442442
"colorPickerLabel": "{label} color picker"
443+
},
444+
"notificationCenter": {
445+
"buttonLabel": "Notifications",
446+
"buttonLabelWithCount": "Notifications ({count} unread)",
447+
"panelLabel": "Notification Center",
448+
"heading": "Notifications",
449+
"unreadCount": "{count} unread",
450+
"noAlerts": "No new alerts",
451+
"alertLabel": "Alert",
452+
"dismissLabel": "Dismiss notification: {message}",
453+
"notificationLabel": "Notification: {message}",
454+
"timestampLabel": "Timestamp: {timestamp}"
443455
}
444456
}

frontend/messages/es.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,17 @@
440440
"failedToTestWebhook": "Error al probar el webhook",
441441
"testWebhookSentStatus": "Webhook de prueba enviado — estado {status}",
442442
"colorPickerLabel": "Selector de color {label}"
443+
},
444+
"notificationCenter": {
445+
"buttonLabel": "Notificaciones",
446+
"buttonLabelWithCount": "Notificaciones ({count} sin leer)",
447+
"panelLabel": "Centro de Notificaciones",
448+
"heading": "Notificaciones",
449+
"unreadCount": "{count} sin leer",
450+
"noAlerts": "Sin nuevas alertas",
451+
"alertLabel": "Alerta",
452+
"dismissLabel": "Descartar notificación: {message}",
453+
"notificationLabel": "Notificación: {message}",
454+
"timestampLabel": "Marca de tiempo: {timestamp}"
443455
}
444456
}

frontend/messages/pt.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,17 @@
440440
"failedToTestWebhook": "Falha ao testar o webhook",
441441
"testWebhookSentStatus": "Webhook de teste enviado — status {status}",
442442
"colorPickerLabel": "Seletor de cor {label}"
443+
},
444+
"notificationCenter": {
445+
"buttonLabel": "Notificacoes",
446+
"buttonLabelWithCount": "Notificacoes ({count} nao lidas)",
447+
"panelLabel": "Central de Notificacoes",
448+
"heading": "Notificacoes",
449+
"unreadCount": "{count} nao lidas",
450+
"noAlerts": "Sem novos alertas",
451+
"alertLabel": "Alerta",
452+
"dismissLabel": "Dispensar notificacao: {message}",
453+
"notificationLabel": "Notificacao: {message}",
454+
"timestampLabel": "Carimbo de data/hora: {timestamp}"
443455
}
444456
}

frontend/src/app/(authenticated)/layout.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
"use client";
22

33
import { useState } from "react";
4+
import dynamic from "next/dynamic";
45
import AuthGuard from "@/components/AuthGuard";
56
import Breadcrumbs from "@/components/Breadcrumbs";
67
import LocaleSwitcher from "@/components/LocaleSwitcher";
7-
import NotificationCenter from "@/components/NotificationCenter";
88
import PaymentToastListener from "@/components/PaymentToastListener";
99
import Sidebar from "@/components/Sidebar";
1010
import SupportOverlay from "@/components/SupportOverlay";
1111
import UserNav from "@/components/UserNav";
1212
import { useHydrateMerchantStore } from "@/lib/merchant-store";
1313
import { motion } from "framer-motion";
1414

15+
/**
16+
* #1189 — Bundle size optimisation:
17+
* NotificationCenter (which pulls in framer-motion panel logic) is loaded
18+
* dynamically so it does not bloat the initial authenticated-layout bundle.
19+
* The bell trigger itself is tiny, so SSR is disabled — it is purely
20+
* interactive and only meaningful after hydration.
21+
*/
22+
const NotificationCenter = dynamic(
23+
() => import("@/components/NotificationCenter"),
24+
{ ssr: false }
25+
);
26+
1527
export default function AuthenticatedLayout({
1628
children,
1729
}: {

frontend/src/app/(authenticated)/payment-history/page.tsx

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
11
"use client";
22

3-
import { useCallback, useEffect, useState } from "react";
3+
import dynamic from "next/dynamic";
4+
import { useCallback, useEffect, useMemo, useState } from "react";
45
import { usePathname, useRouter, useSearchParams } from "next/navigation";
56
import { useLocale, useTranslations } from "next-intl";
67
import Skeleton from "react-loading-skeleton";
78
import "react-loading-skeleton/dist/skeleton.css";
8-
import PaymentDetailModal from "@/components/PaymentDetailModal";
9-
import PaymentDetailsSheet from "@/components/PaymentDetailsSheet";
10-
import ExportCsvButton from "@/components/ExportCsvButton";
11-
import TransactionFilterSidebar from "@/components/TransactionFilterSidebar";
129
import { localeToLanguageTag } from "@/i18n/config";
1310
import { toast } from "sonner";
1411
import {
1512
useHydrateMerchantStore,
1613
useMerchantApiKey,
1714
useMerchantId,
1815
} from "@/lib/merchant-store";
19-
import { buildPaymentHistorySearchParams } from "@/lib/payment-history-filters";
2016
import { useTransactionFilters } from "@/hooks/useTransactionFilters";
2117
import { usePaymentSocket } from "@/lib/usePaymentSocket";
2218

19+
const ExportCsvButton = dynamic(() => import("@/components/ExportCsvButton"), {
20+
ssr: false,
21+
loading: () => (
22+
<div className="h-10 w-28 rounded-xl border border-[#E8E8E8] bg-[#F9F9F9]" aria-hidden="true" />
23+
),
24+
});
25+
const PaymentDetailModal = dynamic(() => import("@/components/PaymentDetailModal"), { ssr: false });
26+
const PaymentDetailsSheet = dynamic(() => import("@/components/PaymentDetailsSheet"), { ssr: false });
27+
const TransactionFilterSidebar = dynamic(() => import("@/components/TransactionFilterSidebar"), {
28+
ssr: false,
29+
loading: () => (
30+
<div className="hidden h-[420px] w-[320px] rounded-2xl border border-[#E8E8E8] bg-[#F9F9F9] lg:block" aria-hidden="true" />
31+
),
32+
});
33+
2334
interface Payment {
2435
id: string;
2536
amount: string;
@@ -60,6 +71,10 @@ export default function PaymentHistoryPage() {
6071
const searchParams = useSearchParams();
6172
const apiKey = useMerchantApiKey();
6273
const merchantId = useMerchantId();
74+
const currentPage = useMemo(() => {
75+
const parsed = Number(searchParams.get("page") ?? "1");
76+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1;
77+
}, [searchParams]);
6378

6479
useHydrateMerchantStore();
6580

@@ -81,6 +96,19 @@ export default function PaymentHistoryPage() {
8196
onClearFilter,
8297
onClearAll,
8398
} = useTransactionFilters(pushSearchParams, searchParams);
99+
const handlePageChange = useCallback(
100+
(page: number) => {
101+
const nextPage = Math.max(1, page);
102+
const params = new URLSearchParams(searchParams.toString());
103+
if (nextPage === 1) {
104+
params.delete("page");
105+
} else {
106+
params.set("page", String(nextPage));
107+
}
108+
pushSearchParams(params);
109+
},
110+
[pushSearchParams, searchParams],
111+
);
84112

85113
// ── UI state ────────────────────────────────────────────────────────────────
86114
const [payments, setPayments] = useState<Payment[]>([]);
@@ -159,7 +187,7 @@ export default function PaymentHistoryPage() {
159187

160188
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
161189
const params = new URLSearchParams(searchParams.toString());
162-
params.set("page", "1");
190+
params.set("page", currentPage.toString());
163191
params.set("limit", LIMIT.toString());
164192

165193
const response = await fetch(`${apiUrl}/api/payments?${params.toString()}`, {
@@ -182,13 +210,16 @@ export default function PaymentHistoryPage() {
182210

183211
fetchPayments();
184212
return () => controller.abort();
185-
}, [searchParams, apiKey, t]);
213+
}, [searchParams, currentPage, apiKey, t]);
186214

187215
// ── Handlers ─────────────────────────────────────────────────────────────────
188216
const handlePaymentClick = (paymentId: string) => {
189217
setSelectedPayment(paymentId);
190218
setIsSheetOpen(true);
191219
};
220+
const totalPages = Math.max(1, Math.ceil(totalCount / LIMIT));
221+
const pageStart = totalCount === 0 ? 0 : (currentPage - 1) * LIMIT + 1;
222+
const pageEnd = Math.min(currentPage * LIMIT, totalCount);
192223

193224
// ── Loading state ─────────────────────────────────────────────────────────────
194225
if (loading) {
@@ -430,7 +461,9 @@ export default function PaymentHistoryPage() {
430461
{/* Results count */}
431462
<div className="flex items-center justify-between px-2">
432463
<p className="text-xs text-[#6B6B6B] font-medium">
433-
{t("showingResults", { shown: payments.length, total: totalCount })}
464+
{totalPages > 1
465+
? `Showing ${pageStart}-${pageEnd} of ${totalCount}`
466+
: t("showingResults", { shown: payments.length, total: totalCount })}
434467
</p>
435468
</div>
436469

@@ -501,9 +534,29 @@ export default function PaymentHistoryPage() {
501534
</div>
502535
)}
503536

504-
{totalCount > LIMIT && (
505-
<div className="flex items-center justify-center py-6">
506-
<p className="text-[10px] font-bold uppercase tracking-widest text-[#A0A0A0]">End of list (Showing {LIMIT} most recent)</p>
537+
{totalPages > 1 && (
538+
<div className="flex flex-col items-center justify-between gap-3 border-t border-[#F0F0F0] py-6 sm:flex-row">
539+
<p className="text-[10px] font-bold uppercase tracking-widest text-[#A0A0A0]">
540+
Page {currentPage} of {totalPages}
541+
</p>
542+
<nav className="flex items-center gap-2" aria-label="Transaction history pagination">
543+
<button
544+
type="button"
545+
onClick={() => handlePageChange(currentPage - 1)}
546+
disabled={currentPage <= 1 || isFilterPending}
547+
className="inline-flex min-h-10 items-center rounded-xl border border-[#E8E8E8] bg-white px-4 text-[10px] font-bold uppercase tracking-widest text-[#0A0A0A] transition-all hover:bg-[#F5F5F5] disabled:cursor-not-allowed disabled:opacity-40"
548+
>
549+
Previous
550+
</button>
551+
<button
552+
type="button"
553+
onClick={() => handlePageChange(currentPage + 1)}
554+
disabled={currentPage >= totalPages || isFilterPending}
555+
className="inline-flex min-h-10 items-center rounded-xl bg-[#0A0A0A] px-4 text-[10px] font-bold uppercase tracking-widest text-white transition-all hover:bg-[#2A2A2A] disabled:cursor-not-allowed disabled:opacity-40"
556+
>
557+
Next
558+
</button>
559+
</nav>
507560
</div>
508561
)}
509562
</div>
@@ -518,4 +571,4 @@ export default function PaymentHistoryPage() {
518571
)}
519572
</div>
520573
);
521-
}
574+
}

frontend/src/app/(authenticated)/settings/SettingsDashboardClient.tsx renamed to frontend/src/app/(authenticated)/settings/SettingsWidget.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import dynamic from "next/dynamic";
34
import {
45
useCallback,
56
useEffect,
@@ -25,7 +26,6 @@ import { useDisplayPreferences } from "@/lib/display-preferences";
2526
import WebhookHealthIndicator from "@/components/WebhookHealthIndicator";
2627
import DangerZone from "@/components/DangerZone";
2728
import { EmailReceiptPreview } from "@/components/EmailReceiptPreview";
28-
import UserPermissionsManager from "@/components/UserPermissionsManager";
2929
import SettingsPanelSkeleton from "@/components/SettingsPanelSkeleton";
3030
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
3131
import { Spinner } from "@/components/ui/Spinner";
@@ -36,6 +36,14 @@ import {
3636
type SettingsTab,
3737
} from "./accessibility";
3838

39+
const UserPermissionsManager = dynamic(
40+
() => import("@/components/UserPermissionsManager"),
41+
{
42+
ssr: false,
43+
loading: () => <SettingsPanelSkeleton />,
44+
},
45+
);
46+
3947
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
4048
const HEX_COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
4149
const DEFAULT_BRANDING = {
@@ -268,7 +276,7 @@ function buildNavItems(t: (key: string) => string): {
268276
];
269277
}
270278

271-
export default function SettingsDashboardClient() {
279+
export default function SettingsWidget() {
272280
const t = useTranslations("settingsPage");
273281
const navItems = useMemo(() => buildNavItems(t), [t]);
274282
const apiKey = useMerchantApiKey();
@@ -1364,7 +1372,16 @@ export default function SettingsDashboardClient() {
13641372

13651373
{/* Permissions Tab */}
13661374
{activeTab === "permissions" && (
1367-
<UserPermissionsManager />
1375+
<div
1376+
id={getSettingsPanelDomId("permissions")}
1377+
role="tabpanel"
1378+
aria-label={t("navPermissions")}
1379+
aria-labelledby="permissions-tab permissions-tab-mobile"
1380+
tabIndex={0}
1381+
className="rounded-2xl border border-[#E8E8E8] bg-white p-8"
1382+
>
1383+
<UserPermissionsManager showCategories />
1384+
</div>
13681385
)}
13691386

13701387
{/* Danger Tab */}

0 commit comments

Comments
 (0)