Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions PULL_REQUEST_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Pull Request Description

## Title
`feat(frontend): improve accessibility, enable optimistic user permissions, and enhance network status dashboard controls`

## Overview
This PR introduces frontend accessibility enhancements, state refactoring, and polished visual micro-interactions across the Pluto dashboard framework. It successfully addresses four frontend tasks (#821, #822, #823, and #824) while ensuring total system compile stability.

---

## Detailed Changes

### 1. Network Status Indicator (`ApiHealthBadge.tsx`)
* **Interactive Controls**: Refactored the container from a passive `div` to a semantic `<button>` to enable keyboard focus and manual rechecking.
* **Optimistic Update**: Transitioning immediately to a `"loading"` state upon mouse click or Enter keypress for a responsive feedback loop, before initiating the real backend health endpoint check.
* **Advanced Accessibility (Screen Readers & Keyboard)**:
- Added descriptive `aria-live="polite"` tags to report status updates to screen readers dynamically.
- Linked detailed status/degradation alerts using `aria-describedby` referencing the hover/focus tooltip.
- Formulated precise dynamic `aria-label` definitions announcing the connection quality.
- Enabled tooltips on tab focus using standard Tailwind `group-focus-visible` styles.

### 2. Premium User Permissions Manager (`UserPermissionsManager.tsx` & `settings/page.tsx`)
* **Premium Settings Panel**: Integrated a new high-fidelity **Permissions & Team** tab under merchant settings complete with clean user group icons and layout selectors.
* **Optimistic State Actions & Rollbacks**:
- Implemented persistent browser storage (`localStorage`) synced with memory.
- Invites, role transitions, and member revoking are computed **optimistically**. Rows update instantly in the UI with state snapshots saved beforehand.
- In the event of a simulated API failure, the component runs automatic rollback procedures to revert the UI state cleanly and prompts the user via toast notifications.
* **Dynamic Animations**:
- Integrated Framer Motion's `<AnimatePresence>` to animate layout shifts, spring updates, and fade transitions during addition, removal, or filtering of rows.

### 3. Dashboard Webpack Compile Fix (`dashboard/page.tsx`)
* Corrected a pre-existing webpack path resolution error where `dashboard/page.tsx` attempted to load `WithdrawModal` instead of `WithdrawalModal`. The build compiles cleanly with no fatal errors or runtime blockages.

### 4. Code Cleanup & Lints (`WalletSelector.tsx`)
* Removed an unused `useMemo` React import that triggered pre-existing linter warnings.

---

## Verification & Build Log
* **ESLint Check**: Passed with `✔ No ESLint warnings or errors`.
* **Next.js Production Compilation**: Verified via `pnpm run build` compiling `20/20` pages successfully with zero failures.

---

## Linked Issues

Closes #821
Closes #822
Closes #823
Closes #824
2 changes: 1 addition & 1 deletion frontend/public/sw.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/public/workbox-3c9d0171.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/src/app/(authenticated)/settings/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const SETTINGS_TABS = [
"branding",
"display",
"webhooks",
"permissions",
"danger",
] as const;

Expand Down
26 changes: 26 additions & 0 deletions frontend/src/app/(authenticated)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useDisplayPreferences } from "@/lib/display-preferences";
import WebhookHealthIndicator from "@/components/WebhookHealthIndicator";
import DangerZone from "@/components/DangerZone";
import { EmailReceiptPreview } from "@/components/EmailReceiptPreview";
import UserPermissionsManager from "@/components/UserPermissionsManager";
import {
getNextSettingsTab,
getSettingsPanelDomId,
Expand All @@ -39,6 +40,7 @@ const DEFAULT_BRANDING = {
logo_url: null as string | null,
};


interface WebhookDomainVerification {
status: "verified" | "unverified";
domain: string | null;
Expand Down Expand Up @@ -213,6 +215,25 @@ const NAV_ITEMS: {
</svg>
),
},
{
id: "permissions",
label: "Permissions",
icon: (
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
{
id: "danger",
label: "Danger Zone",
Expand Down Expand Up @@ -1267,6 +1288,11 @@ export default function SettingsPage() {
</div>
)}

{/* Permissions Tab */}
{activeTab === "permissions" && (
<UserPermissionsManager />
)}

{/* Danger Tab */}
{activeTab === "danger" && (
<div
Expand Down
98 changes: 56 additions & 42 deletions frontend/src/components/ApiHealthBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,61 @@ export default function ApiHealthBadge() {
const [status, setStatus] = useState<ExtendedHealthStatus>("loading");
const [errorMsg, setErrorMsg] = useState<string | null>(null);

useEffect(() => {
let mounted = true;
const checkHealth = async () => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
const res = await fetch(`${apiUrl}/health`, { cache: "no-store" });
const data = await res.json().catch(() => ({}));
const checkHealth = async () => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
const res = await fetch(`${apiUrl}/health`, { cache: "no-store" });
const data = await res.json().catch(() => ({}));

if (mounted) {
const services = data?.services ?? {};
const dbOk = services.database === "ok";
const horizonOk = services.horizon === "ok";
const apiReachable = true; // If we got an HTTP response, backend is reachable.
const healthy = apiReachable;
const services = data?.services ?? {};
const dbOk = services.database === "ok";
const horizonOk = services.horizon === "ok";
const apiReachable = true; // If we got an HTTP response, backend is reachable.
const healthy = apiReachable;

if (healthy) {
setStatus("healthy");
const missing: string[] = [];
if (!dbOk) missing.push("database");
if (!horizonOk) missing.push("horizon");
setErrorMsg(
missing.length > 0
? `Degraded dependency: ${missing.join(" + ")} unavailable`
: null,
);
} else {
setStatus("error");
setErrorMsg(data?.error || "Backend unavailable");
}
}
} catch {
if (mounted) {
setStatus("error");
setErrorMsg("API Unreachable");
}
if (healthy) {
setStatus("healthy");
const missing: string[] = [];
if (!dbOk) missing.push("database");
if (!horizonOk) missing.push("horizon");
setErrorMsg(
missing.length > 0
? `Degraded dependency: ${missing.join(" + ")} unavailable`
: null,
);
} else {
setStatus("error");
setErrorMsg(data?.error || "Backend unavailable");
}
};
} catch {
setStatus("error");
setErrorMsg("API Unreachable");
}
};

useEffect(() => {
checkHealth();
// Re-check every 60 seconds
const interval = setInterval(checkHealth, 60000);
return () => {
mounted = false;
clearInterval(interval);
};
}, []);

const handleRefresh = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (status === "loading") return;

// Optimistic Update: Immediately transition to visual checking/loading state
setStatus("loading");
setErrorMsg(null);

// satisfy with a micro-delay so the animation feels responsive and interactive
await new Promise((resolve) => setTimeout(resolve, 400));
await checkHealth();
};

const config = {
loading: {
color: "bg-[#E8E8E8]",
Expand All @@ -77,9 +85,14 @@ export default function ApiHealthBadge() {
}[status];

return (
<div
className="group relative flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-white px-3.5 py-2 transition-all duration-300 hover:border-[var(--pluto-300)] hover:bg-[var(--pluto-50)]/50 cursor-default"
aria-describedby="api-status-tooltip"
<button
type="button"
onClick={handleRefresh}
disabled={status === "loading"}
aria-live="polite"
aria-describedby="api-health-tooltip"
aria-label={`API Health Status: ${status === "healthy" ? "Active" : status === "error" ? "Down" : "Checking"} ${errorMsg ? `- ${errorMsg}` : ""}. Click to re-check.`}
className="group relative flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-white px-3.5 py-2 transition-all duration-300 hover:border-[var(--pluto-300)] hover:bg-[var(--pluto-50)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[var(--pluto-300)] active:scale-[0.97] disabled:opacity-85 disabled:cursor-not-allowed cursor-pointer"
>
<div className="relative flex h-2 w-2 items-center justify-center">
{status !== "loading" && (
Expand All @@ -94,10 +107,10 @@ export default function ApiHealthBadge() {
</span>

{/* Tooltip */}
<div
id="api-status-tooltip"
<div
id="api-health-tooltip"
role="tooltip"
className="pointer-events-none absolute left-1/2 top-full z-50 mt-4 -translate-x-1/2 whitespace-nowrap rounded-2xl border border-[var(--pluto-100)] bg-white/95 px-5 py-3.5 text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--text-primary)] opacity-0 shadow-[0_20px_50px_rgba(0,0,0,0.12)] backdrop-blur-md transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-1.5"
className="pointer-events-none absolute left-1/2 top-full z-50 mt-4 -translate-x-1/2 whitespace-nowrap rounded-2xl border border-[var(--pluto-100)] bg-white/95 px-5 py-3.5 text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--text-primary)] opacity-0 shadow-[0_20px_50px_rgba(0,0,0,0.12)] backdrop-blur-md transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-1.5 group-focus-visible:opacity-100 group-focus-visible:translate-y-1.5"
>
<p className="text-center leading-none">{config.label}</p>
{(errorMsg && status !== "loading") && (
Expand All @@ -106,6 +119,7 @@ export default function ApiHealthBadge() {
{/* Arrow */}
<div className="absolute bottom-full left-1/2 h-2.5 w-2.5 -translate-x-1/2 translate-y-1.5 rotate-45 border-l border-t border-[var(--pluto-100)] bg-white/95" />
</div>
</div>
</button>
);
}

Loading
Loading