From 81d396a8206edc76bbd1a067ae1ede5d0d83bff3 Mon Sep 17 00:00:00 2001 From: onyillto <56700691+onyillto@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:33:38 +0100 Subject: [PATCH] Create page.tsx fixes:304 --- frontend/src/app/settings/page.tsx | 547 +++++++++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 frontend/src/app/settings/page.tsx diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 00000000..ce4f48ab --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,547 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import CopyButton from "@/components/CopyButton"; +import toast from "react-hot-toast"; +import { + useHydrateMerchantStore, + useMerchantApiKey, + useMerchantHydrated, + useSetMerchantApiKey, +} from "@/lib/merchant-store"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; +const HEX_COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; +const DEFAULT_BRANDING = { + primary_color: "#5ef2c0", + secondary_color: "#b8ffe2", + background_color: "#050608", +}; + +type SettingsTab = "api" | "branding"; + +function normalizeHexInput(value: string) { + const trimmed = value.trim(); + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; +} + +function hexToRgb(hex: string) { + const clean = hex.replace("#", ""); + const full = clean.length === 3 + ? clean.split("").map((c) => `${c}${c}`).join("") + : clean; + const int = Number.parseInt(full, 16); + + return { + r: (int >> 16) & 255, + g: (int >> 8) & 255, + b: int & 255, + }; +} + +function luminance(hex: string) { + const { r, g, b } = hexToRgb(hex); + const transform = (value: number) => { + const channel = value / 255; + return channel <= 0.03928 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * transform(r) + 0.7152 * transform(g) + 0.0722 * transform(b); +} + +function contrastRatio(foregroundHex: string, backgroundHex: string) { + const l1 = luminance(foregroundHex); + const l2 = luminance(backgroundHex); + const brighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (brighter + 0.05) / (darker + 0.05); +} + +// ─── Eye icon (show / hide key) ────────────────────────────────────────────── + +function EyeIcon({ open }: { open: boolean }) { + return open ? ( + + + + + ) : ( + + + + + ); +} + +// ─── Masked key display ─────────────────────────────────────────────────────── + +function mask(key: string) { + if (key.length <= 12) return "•".repeat(key.length); + return key.slice(0, 7) + "•".repeat(key.length - 13) + key.slice(-6); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function SettingsPage() { + const apiKey = useMerchantApiKey(); + const hydrated = useMerchantHydrated(); + const setApiKey = useSetMerchantApiKey(); + + const [revealed, setRevealed] = useState(false); + + // Rotation flow state + const [confirming, setConfirming] = useState(false); + const [rotating, setRotating] = useState(false); + const [rotateError, setRotateError] = useState(null); + const [activeTab, setActiveTab] = useState("api"); + const [branding, setBranding] = useState(DEFAULT_BRANDING); + const [brandingError, setBrandingError] = useState(null); + const [loadingBranding, setLoadingBranding] = useState(false); + const [savingBranding, setSavingBranding] = useState(false); + + useHydrateMerchantStore(); + + useEffect(() => { + if (!apiKey) return; + + const loadBranding = async () => { + setLoadingBranding(true); + setBrandingError(null); + try { + const res = await fetch(`${API_URL}/api/merchant-branding`, { + headers: { "x-api-key": apiKey }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Failed to load branding"); + setBranding(data.branding_config ?? DEFAULT_BRANDING); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to load branding"; + setBrandingError(msg); + } finally { + setLoadingBranding(false); + } + }; + + loadBranding(); + }, [apiKey]); + + const startRotate = () => { + setRotateError(null); + setConfirming(true); + }; + + const cancelRotate = () => { + setConfirming(false); + }; + + const confirmRotate = async () => { + if (!apiKey) return; + setRotating(true); + setRotateError(null); + + try { + const res = await fetch(`${API_URL}/api/rotate-key`, { + method: "POST", + headers: { "x-api-key": apiKey }, + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Failed to rotate key"); + + const newKey: string = data.api_key; + setApiKey(newKey); + setRevealed(true); // show the new key immediately + setConfirming(false); + toast.success( + "API key rotated — update any integrations using the old key.", + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to rotate key"; + setRotateError(msg); + toast.error(msg); + } finally { + setRotating(false); + } + }; + + const updateBrandingField = ( + key: keyof typeof DEFAULT_BRANDING, + value: string, + ) => { + setBranding((current) => ({ + ...current, + [key]: normalizeHexInput(value), + })); + }; + + const saveBranding = async () => { + if (!apiKey) return; + setBrandingError(null); + + for (const [key, color] of Object.entries(branding)) { + if (!HEX_COLOR_REGEX.test(color)) { + setBrandingError(`${key} must be a valid hex color`); + return; + } + } + + setSavingBranding(true); + try { + const res = await fetch(`${API_URL}/api/merchant-branding`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(branding), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Failed to save branding"); + setBranding(data.branding_config ?? branding); + toast.success("Branding saved"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to save branding"; + setBrandingError(msg); + toast.error(msg); + } finally { + setSavingBranding(false); + } + }; + + // ── Await hydration ────────────────────────────────────────────────────── + if (!hydrated) return null; + + // ── No key stored ──────────────────────────────────────────────────────── + if (!apiKey) { + return ( +
+
+

+ Settings +

+

Merchant Settings

+
+ +
+

+ No API key found +

+

+ Register a merchant account first to manage your credentials here. +

+ + Register as Merchant + +
+
+ ); + } + + const displayKey = revealed ? apiKey : mask(apiKey); + const primaryOnBackground = contrastRatio( + branding.primary_color, + branding.background_color, + ); + const secondaryOnBackground = contrastRatio( + branding.secondary_color, + branding.background_color, + ); + const lowContrastWarning = + primaryOnBackground < 4.5 || secondaryOnBackground < 3; + + return ( +
+ {/* ── Header ── */} +
+

+ Settings +

+

Merchant Settings

+

+ Manage your API credentials. Keep your key secret — treat it like a + password. +

+
+ + {/* ── Main card ── */} +
+
+ + +
+ + {activeTab === "api" ?
+ {/* API Key section */} +
+
+

+ API Key +

+ +
+ +
+ + {displayKey} + + {/* Only allow copying when revealed to prevent accidental exposure */} + {revealed && } +
+ +

+ Pass this as the x-api-key{" "} + header on every API request. +

+
+ + {/* Divider */} +
+ + {/* Rotate Key section */} +
+
+

+ Rotate API Key +

+

+ Generates a new key and immediately invalidates the current one. + Any integration still using the old key will stop working. +

+
+ + {rotateError && ( +
+ {rotateError} +
+ )} + + {!confirming ? ( + + ) : ( +
+

+ Are you sure? This cannot be undone. +

+

+ The old key will stop working immediately. Make sure to update + all your integrations with the new key. +

+
+ + +
+
+ )} +
+
: ( +
+
+

+ Checkout Branding +

+

+ Set default checkout colors. These values are exposed as CSS variables and can be overridden per session. +

+
+ + {brandingError && ( +
+ {brandingError} +
+ )} + {lowContrastWarning && ( +
+ Selected colors may not meet WCAG contrast targets (4.5:1 for body text). Consider adjusting primary or background colors. +
+ )} + +
+ {([ + ["primary_color", "Primary Color"], + ["secondary_color", "Secondary Color"], + ["background_color", "Background Color"], + ] as const).map(([field, label]) => ( + + ))} +
+ +
+

+ Preview +

+
+

Sample checkout card

+ +
+
+ + +
+ )} +
+ + {/* Footer nav */} + +
+ ); +}