diff --git a/backend/logs/audit_fallback.log b/backend/logs/audit_fallback.log index ac0eeb2a..50392800 100644 --- a/backend/logs/audit_fallback.log +++ b/backend/logs/audit_fallback.log @@ -27,3 +27,10 @@ 2026-06-28T08:03:05.982Z | {"merchant_id":"merchant-uuid-005","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined 2026-06-28T08:03:05.984Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined 2026-06-28T08:03:06.289Z | {"merchant_id":"merchant-uuid-006","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.295Z | {"merchant_id":"merchant-uuid-001","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.324Z | {"merchant_id":"merchant-uuid-002","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.327Z | {"merchant_id":null,"action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.329Z | {"merchant_id":"merchant-uuid-003","action":"login","status":"success","ip_address":null,"user_agent":null,"event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.331Z | {"merchant_id":"merchant-uuid-005","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.351Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-07-24T15:08:33.654Z | {"merchant_id":"merchant-uuid-006","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 6b10a5b7..f5bca4db 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -2,5 +2,26 @@ "extends": [ "next/core-web-vitals", "next/typescript" - ] + ], + "ignorePatterns": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test.js", + "**/*.test.jsx", + "src/test/**", + "tests/**" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-unused-expressions": "warn", + "react-hooks/rules-of-hooks": "warn" + } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3926d57f..dc679ea4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -343,6 +343,7 @@ "eyebrowAccount": "Account", "title": "Settings", "description": "Manage your credentials, branding, and integrations.", + "loadingSettings": "Loading settings…", "noApiKeyTitle": "No API key found", "noApiKeyDescription": "Register a merchant account first to manage your credentials here.", "registerAsMerchant": "Register as Merchant", @@ -415,7 +416,30 @@ "regenerateSecretEllipsis": "Regenerate Secret…", "regenerateSecretWarning": "The current secret will stop working immediately.", "regenerating": "Regenerating…", - "dangerZoneDescription": "Irreversible actions. Proceed with caution." + "dangerZoneDescription": "Irreversible actions. Proceed with caution.", + "failedToLoadBranding": "Failed to load branding", + "failedToLoadWebhookSettings": "Failed to load webhook settings", + "failedToRotateKey": "Failed to rotate key", + "apiKeyRotatedSuccess": "API key rotated — update any integrations using the old key.", + "imageSizeLimit": "Image must be under 2MB", + "logoUploaded": "Logo uploaded!", + "hexColorRequired": "{field} must be a valid hex color", + "failedToSaveBranding": "Failed to save branding", + "brandingSaved": "Branding saved", + "webhookUrlMustBeHttps": "Webhook URL must use HTTPS", + "webhookUrlInvalid": "Invalid URL (e.g. https://example.com/webhook)", + "failedToSaveWebhookUrl": "Failed to save webhook URL", + "webhookUrlSaved": "Webhook URL saved", + "webhookUrlCleared": "Webhook URL cleared", + "failedToVerifyDomain": "Failed to verify webhook domain", + "domainVerified": "Domain verified", + "domainStillUnverified": "Domain still unverified", + "failedToRegenerateSecret": "Failed to regenerate secret", + "webhookSecretRegenerated": "Webhook secret regenerated — update your integrations.", + "testWebhookRequestFailed": "Test webhook request failed", + "failedToTestWebhook": "Failed to test webhook", + "testWebhookSentStatus": "Test webhook sent — status {status}", + "colorPickerLabel": "{label} color picker" }, "notificationCenter": { "buttonLabel": "Notifications", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 574eb051..bfb03270 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -343,6 +343,7 @@ "eyebrowAccount": "Cuenta", "title": "Configuración", "description": "Administra tus credenciales, marca e integraciones.", + "loadingSettings": "Cargando configuración…", "noApiKeyTitle": "No se encontró clave API", "noApiKeyDescription": "Registra una cuenta de comercio primero para administrar tus credenciales aquí.", "registerAsMerchant": "Registrarse como Comercio", @@ -415,7 +416,30 @@ "regenerateSecretEllipsis": "Regenerar Secreto…", "regenerateSecretWarning": "El secreto actual dejará de funcionar inmediatamente.", "regenerating": "Regenerando…", - "dangerZoneDescription": "Acciones irreversibles. Procede con precaución." + "dangerZoneDescription": "Acciones irreversibles. Procede con precaución.", + "failedToLoadBranding": "Error al cargar la marca", + "failedToLoadWebhookSettings": "Error al cargar la configuración del webhook", + "failedToRotateKey": "Error al rotar la clave", + "apiKeyRotatedSuccess": "Clave API rotada — actualiza cualquier integración que use la clave anterior.", + "imageSizeLimit": "La imagen debe ser menor a 2MB", + "logoUploaded": "¡Logo subido!", + "hexColorRequired": "{field} debe ser un color hexadecimal válido", + "failedToSaveBranding": "Error al guardar la marca", + "brandingSaved": "Marca guardada", + "webhookUrlMustBeHttps": "La URL del webhook debe usar HTTPS", + "webhookUrlInvalid": "URL inválida (ej. https://example.com/webhook)", + "failedToSaveWebhookUrl": "Error al guardar la URL del webhook", + "webhookUrlSaved": "URL del webhook guardada", + "webhookUrlCleared": "URL del webhook eliminada", + "failedToVerifyDomain": "Error al verificar el dominio del webhook", + "domainVerified": "Dominio verificado", + "domainStillUnverified": "El dominio sigue sin verificarse", + "failedToRegenerateSecret": "Error al regenerar el secreto", + "webhookSecretRegenerated": "Secreto del webhook regenerado — actualiza tus integraciones.", + "testWebhookRequestFailed": "Falló la solicitud de prueba del webhook", + "failedToTestWebhook": "Error al probar el webhook", + "testWebhookSentStatus": "Webhook de prueba enviado — estado {status}", + "colorPickerLabel": "Selector de color {label}" }, "notificationCenter": { "buttonLabel": "Notificaciones", diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 8617a469..a2df3ab8 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -343,6 +343,7 @@ "eyebrowAccount": "Conta", "title": "Configuracoes", "description": "Gerencie suas credenciais, marca e integracoes.", + "loadingSettings": "Carregando configuracoes…", "noApiKeyTitle": "Nenhuma chave de API encontrada", "noApiKeyDescription": "Registre uma conta de lojista primeiro para gerenciar suas credenciais aqui.", "registerAsMerchant": "Registrar como Lojista", @@ -415,7 +416,30 @@ "regenerateSecretEllipsis": "Regenerar Segredo…", "regenerateSecretWarning": "O segredo atual deixara de funcionar imediatamente.", "regenerating": "Regenerando…", - "dangerZoneDescription": "Acoes irreversiveis. Prossiga com cautela." + "dangerZoneDescription": "Acoes irreversiveis. Prossiga com cautela.", + "failedToLoadBranding": "Falha ao carregar a marca", + "failedToLoadWebhookSettings": "Falha ao carregar as configuracoes do webhook", + "failedToRotateKey": "Falha ao rotacionar a chave", + "apiKeyRotatedSuccess": "Chave de API rotacionada — atualize as integracoes que usam a chave antiga.", + "imageSizeLimit": "A imagem deve ter menos de 2MB", + "logoUploaded": "Logo enviado!", + "hexColorRequired": "{field} deve ser uma cor hexadecimal valida", + "failedToSaveBranding": "Falha ao salvar a marca", + "brandingSaved": "Marca salva", + "webhookUrlMustBeHttps": "A URL do webhook deve usar HTTPS", + "webhookUrlInvalid": "URL invalida (ex. https://example.com/webhook)", + "failedToSaveWebhookUrl": "Falha ao salvar a URL do webhook", + "webhookUrlSaved": "URL do webhook salva", + "webhookUrlCleared": "URL do webhook removida", + "failedToVerifyDomain": "Falha ao verificar o dominio do webhook", + "domainVerified": "Dominio verificado", + "domainStillUnverified": "Dominio ainda nao verificado", + "failedToRegenerateSecret": "Falha ao regenerar o segredo", + "webhookSecretRegenerated": "Segredo do webhook regenerado — atualize suas integracoes.", + "testWebhookRequestFailed": "Falha na solicitacao de teste do webhook", + "failedToTestWebhook": "Falha ao testar o webhook", + "testWebhookSentStatus": "Webhook de teste enviado — status {status}", + "colorPickerLabel": "Seletor de cor {label}" }, "notificationCenter": { "buttonLabel": "Notificacoes", diff --git a/frontend/next.config.js b/frontend/next.config.js index 19044089..32baa471 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -10,7 +10,7 @@ let withPWA = (config) => config; try { withPWA = require("@ducanh2912/next-pwa").default({ dest: "public", - disable: process.env.NODE_ENV === "development", + disable: process.env.NODE_ENV === "development" || process.env.CI === "true", register: true, skipWaiting: true, fallbacks: { diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..d7380c54 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1 @@ +if(!self.define){let e,s={};const a=(a,t)=>(a=new URL(a+".js",t).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(t,n)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>a(e,c),u={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>u[e]||r(e))).then(e=>(n(...e),i))}}define(["./workbox-3c9d0171"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1048.cf2dae2f0fde1da9.js",revision:"cf2dae2f0fde1da9"},{url:"/_next/static/chunks/1500.c77dfa6fd17c3476.js",revision:"c77dfa6fd17c3476"},{url:"/_next/static/chunks/1518-5f8ba88167b80d7f.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/1950.829e8ddaa0002236.js",revision:"829e8ddaa0002236"},{url:"/_next/static/chunks/3440-9e80795405a7b651.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/4044-e0c4400a456330f6.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/4153-b830338670d9133f.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/501.b61c1c81a2756938.js",revision:"b61c1c81a2756938"},{url:"/_next/static/chunks/5499-689f271e848828cf.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/5645-0adafa16708b4424.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/5696-c6295521789d17fc.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/598a0686-dd189625a5f5fcfa.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/5991-76296ffad7b9c513.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/6507-4daccdddf4315246.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/6777.dda21fdfbca560f9.js",revision:"dda21fdfbca560f9"},{url:"/_next/static/chunks/6800-6711b0bd10904918.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/6909-70f077cd1780031a.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/6932-d0a2950d6f0d1438.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/7043-c6e4cf9e65aff083.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/7275.4779e36fffacc14e.js",revision:"4779e36fffacc14e"},{url:"/_next/static/chunks/7394-c140ab05eff9cff0.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/7862.abc8647bd0e5c4f0.js",revision:"abc8647bd0e5c4f0"},{url:"/_next/static/chunks/7cdb0f48-65b7d11b8fca7be3.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/8588.acdb785e3a5e2b6f.js",revision:"acdb785e3a5e2b6f"},{url:"/_next/static/chunks/8657-36e6dca802d1e7ee.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/8956.9a6f1fd4435cfc51.js",revision:"9a6f1fd4435cfc51"},{url:"/_next/static/chunks/8996-084368a44bee4ec3.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/8f00c967-4ea985aa93cd54d7.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/9177-fc65b867cb6a6775.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/9688-a54d9e4568ed3609.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/api-keys/page-24bacadbdfbd2039.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/create/page-b85cd774e757ab99.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/dashboard/create/page-93345147a32a19b5.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/dashboard/dev-tools/page-aab0248bd7799dd8.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/dashboard/page-6d912a1d6d1879ea.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/kyc/page-6298b48791c8337c.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/layout-78015b6a29aa26f7.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/payment-history/page-95f4ff63c7aeeb80.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/payments/page-f0899699d041d24b.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/settings/page-ac934b043f69ce6e.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(authenticated)/webhook-logs/page-983df88d14d05c07.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/docs/%5Bslug%5D/page-f19f1cd497a974b6.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/docs/layout-6a8b8b77e8c99c43.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/docs/page-9e67cb71c2f5326b.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/forgot-password/page-2b954a06a1fca253.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/layout-4518c6ced49d157b.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/login/page-28273fbb80d2dd10.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/page-38f6087f6272942c.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/register/page-068b570883f1e460.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/(public)/vrt/page-66b3e9288766d72b.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/_not-found/page-75c8c2e2608e7518.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/error-45ca0800d2229644.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/global-error-47cee7d3143539f0.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/layout-298a715cdbbacc6c.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/not-found-6abf18dc73c9645f.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/offline/page-44aa8fff857c1967.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/pay/%5Bid%5D/cancelled/page-f5537931674e4a24.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/pay/%5Bid%5D/layout-1bbcb35a9a93cfc9.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/app/pay/%5Bid%5D/page-ba0a15766552f43e.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/framework-63a5d844a3662ade.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/main-5ebfb49a357c6385.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/main-app-0e7ecd754846ca15.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/pages/_app-8e5023ffb26b6360.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/pages/_error-e9a178df62183381.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-cd656fe24d5a43dc.js",revision:"xQaJ2lMJOK5BVA13gzARB"},{url:"/_next/static/css/20720a659c31249d.css",revision:"20720a659c31249d"},{url:"/_next/static/css/69b92bd7edc4f56a.css",revision:"69b92bd7edc4f56a"},{url:"/_next/static/xQaJ2lMJOK5BVA13gzARB/_buildManifest.js",revision:"e0db3bef8f0c4bfc3f9ac336f39435bf"},{url:"/_next/static/xQaJ2lMJOK5BVA13gzARB/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/icons/icon-192x192.png",revision:"e84691a47a7e3edcf6f9109036b52a12"},{url:"/icons/icon-512x512.png",revision:"e84691a47a7e3edcf6f9109036b52a12"},{url:"/manifest.json",revision:"a0a82257f255bd11ad6c5c683311857e"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({response:e})=>e&&"opaqueredirect"===e.type?new Response(e.body,{status:200,statusText:"OK",headers:e.headers}):e}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/_next\/static.+\.js$/i,new e.CacheFirst({cacheName:"next-static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4|webm)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:48,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e,url:{pathname:s}})=>!(!e||s.startsWith("/api/auth/callback")||!s.startsWith("/api/")),new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc-prefetch",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({request:e,url:{pathname:s},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!s.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages-rsc",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:{pathname:e},sameOrigin:s})=>s&&!e.startsWith("/api/"),new e.NetworkFirst({cacheName:"pages",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({sameOrigin:e})=>!e,new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/frontend/public/workbox-3c9d0171.js b/frontend/public/workbox-3c9d0171.js new file mode 100644 index 00000000..8d64e054 --- /dev/null +++ b/frontend/public/workbox-3c9d0171.js @@ -0,0 +1 @@ +define(["exports"],function(t){"use strict";try{self["workbox:core:7.0.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.0.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:7.0.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:r,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,e){const n=m(t);var r;await(r=0,new Promise(t=>setTimeout(t,r)));const i=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=i.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.R(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const r=p(e.url,s);if(e.url===r)return t.match(e,n);const i=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,i);for(const e of a)if(r===p(e.url,s))return t.match(e,n)}(u,i.clone(),["__WB_REVISION__"],h):null;try{await u.put(i,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of g)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:i,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const r=Object.assign(Object.assign({},n),{state:s});return e[t](r)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async R(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class R{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,r=new v(this,{event:e,request:s,params:n}),i=this.q(r,s,e);return[i,this.D(i,r,s,e)]}async q(t,e,n){let r;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(r=await this.U(e,t),!r||"error"===r.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(r=await i({error:s,event:n,request:e}),r)break;if(!r)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))r=await s({event:n,request:e,response:r});return r}async D(t,e,s,n){let r,i;try{r=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:i}),e.destroy(),i)throw i}}function b(t){t.then(()=>{})}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(B(this),e),k(x.get(this))}:function(...e){return k(t.apply(B(this),e))}:function(e,...s){const n=t.call(B(this),e,...s);return I.set(n,e.sort?e.sort():[e]),k(n)}}function T(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(L.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",r),t.removeEventListener("error",i),t.removeEventListener("abort",i)},r=()=>{e(),n()},i=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",r),t.addEventListener("error",i),t.addEventListener("abort",i)});L.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,N):t);var e}function k(t){if(t instanceof IDBRequest)return function(t){const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("success",r),t.removeEventListener("error",i)},r=()=>{e(k(t.result)),n()},i=()=>{s(t.error),n()};t.addEventListener("success",r),t.addEventListener("error",i)});return e.then(e=>{e instanceof IDBCursor&&x.set(e,t)}).catch(()=>{}),E.set(e,t),e}(t);if(C.has(t))return C.get(t);const e=T(t);return e!==t&&(C.set(t,e),E.set(e,t)),e}const B=t=>E.get(t);const P=["get","getKey","getAll","getAllKeys","count"],M=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,r=M.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!r&&!P.includes(s))return;const i=async function(t,...e){const i=this.transaction(t,r?"readwrite":"readonly");let a=i.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),r&&i.done]))[0]};return W.set(e,i),i}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:7.0.0"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.L=t}I(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.I(t),this.L&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",t=>e(t.oldVersion,t)),k(s).then(()=>{})}(this.L)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.L,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const r=[];let i=0;for(;n;){const s=n.value;s.cacheName===this.L&&(t&&s.timestamp=e?r.push(n.value):i++),n=await n.continue()}const a=[];for(const t of r)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.L+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:r,terminated:i}={}){const a=indexedDB.open(t,e),o=k(a);return n&&a.addEventListener("upgradeneeded",t=>{n(k(a.result),t.oldVersion,t.newVersion,k(a.transaction),t)}),s&&a.addEventListener("blocked",t=>s(t.oldVersion,t.newVersion,t)),o.then(t=>{i&&t.addEventListener("close",()=>i()),r&&t.addEventListener("versionchange",t=>r(t.oldVersion,t.newVersion,t))}).catch(()=>{}),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.T=!1,this.k=e.maxEntries,this.B=e.maxAgeSeconds,this.P=e.matchOptions,this.L=t,this.M=new A(t)}async expireEntries(){if(this.O)return void(this.T=!0);this.O=!0;const t=this.B?Date.now()-1e3*this.B:0,e=await this.M.expireEntries(t,this.k),s=await self.caches.open(this.L);for(const t of e)await s.delete(t,this.P);this.O=!1,this.T&&(this.T=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.M.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.B){const e=await this.M.getTimestamp(t),s=Date.now()-1e3*this.B;return void 0===e||er||e&&e<0)throw new s("range-not-satisfiable",{size:r,end:n,start:e});let i,a;return void 0!==e&&void 0!==n?(i=e,a=n+1):void 0!==e&&void 0===n?(i=e,a=r):void 0!==n&&void 0===e&&(i=r-n,a=r),{start:i,end:a}}(i,r.start,r.end),o=i.slice(a.start,a.end),c=o.size,h=new Response(o,{status:206,statusText:"Partial Content",headers:e.headers});return h.headers.set("Content-Length",String(c)),h.headers.set("Content-Range",`bytes ${a.start}-${a.end-1}/${i.size}`),h}catch(t){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}}function $(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.0.0"]&&_()}catch(t){}function z(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const r=new URL(n,location.href),i=new URL(n,location.href);return r.searchParams.set("__WB_REVISION__",e),{cacheKey:r.href,url:i.href}}class G{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function X(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const r=t.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},a=e?e(i):i,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?r.body:await r.blob();return new Response(o,a)}class Y extends R{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(Y.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const r=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=r.integrity,i=t.integrity,a=!i||i===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?i||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==Y.copyRedirectedCacheableResponsesPlugin&&(n===Y.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(Y.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}Y.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},Y.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await X(t):t};class Z{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new Y({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:r}=z(n),i="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(r)&&this.F.get(r)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(r),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:r});this.$.set(t,n.integrity)}if(this.F.set(r,t),this.H.set(r,i),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return $(t,async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),r=this.H.get(e),i=new Request(e,{integrity:n,cache:r,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:i,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return $(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const r of e)s.has(r.url)||(await t.delete(r),n.push(r.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const tt=()=>(Q||(Q=new Z),Q);class et extends r{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const r of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:r}={}){const i=new URL(t,location.href);i.hash="",yield i.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(i,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(r){const t=r({url:i});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(r);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.CacheFirst=class extends R{async U(t,e){let n,r=await e.cacheMatch(t);if(!r)try{r=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new s("no-response",{url:t.url,error:n});return r}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const r=this.V(n),i=this.J(s);b(i.expireEntries());const a=i.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return r?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.B=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){g.add(t)}(()=>this.deleteCacheAndMetadata())}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.B)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.B}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],r=[];let i;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});i=s,r.push(a)}const a=this.st({timeoutId:i,request:t,logs:n,handler:e});r.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(r))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise(e=>{n=setTimeout(async()=>{e(await s.cacheMatch(t))},1e3*this.tt)}),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let r,i;try{i=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(r=t)}return t&&clearTimeout(t),!r&&i||(i=await n.cacheMatch(e)),i}},t.RangeRequestsPlugin=class{constructor(){this.cachedResponseWillBeUsed=async({request:t,cachedResponse:e})=>e&&t.headers.has("range")?await H(t,e):e}},t.StaleWhileRevalidate=class extends R{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch(()=>{});e.waitUntil(n);let r,i=await e.cacheMatch(t);if(i);else try{i=await n}catch(t){t instanceof Error&&(r=t)}if(!i)throw new s("no-response",{url:t.url,error:r});return i}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){tt().precache(t)}(t),function(t){const e=tt();h(new et(e,t))}(e)},t.registerRoute=h}); diff --git a/frontend/src/app/(authenticated)/settings/SettingsWidget.tsx b/frontend/src/app/(authenticated)/settings/SettingsWidget.tsx index e52011da..bb9b4329 100644 --- a/frontend/src/app/(authenticated)/settings/SettingsWidget.tsx +++ b/frontend/src/app/(authenticated)/settings/SettingsWidget.tsx @@ -1,5 +1,6 @@ "use client"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, @@ -25,8 +26,8 @@ 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 SettingsPanelSkeleton from "@/components/SettingsPanelSkeleton"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import { Spinner } from "@/components/ui/Spinner"; import { getNextSettingsTab, @@ -35,6 +36,14 @@ import { type SettingsTab, } from "./accessibility"; +const UserPermissionsManager = dynamic( + () => import("@/components/UserPermissionsManager"), + { + ssr: false, + loading: () => , + }, +); + 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 = { @@ -43,6 +52,11 @@ const DEFAULT_BRANDING = { background_color: "#050608", logo_url: null as string | null, }; +const BRANDING_FIELD_LABEL_KEYS: Record = { + primary_color: "primary", + secondary_color: "secondary", + background_color: "background", +}; interface WebhookDomainVerification { @@ -315,17 +329,18 @@ export default function SettingsWidget() { headers: { "x-api-key": apiKey }, }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to load branding"); + if (!res.ok) throw new Error(data.error ?? t("failedToLoadBranding")); setBranding(data.branding_config ?? DEFAULT_BRANDING); } catch (err: unknown) { setBrandingError( - err instanceof Error ? err.message : "Failed to load branding", + err instanceof Error ? err.message : t("failedToLoadBranding"), ); } finally { setLoadingBranding(false); } }; load(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- t is not stable across renders and must not retrigger this fetch }, [apiKey]); useEffect(() => { @@ -338,7 +353,7 @@ export default function SettingsWidget() { }); const data = await res.json(); if (!res.ok) - throw new Error(data.error ?? "Failed to load webhook settings"); + throw new Error(data.error ?? t("failedToLoadWebhookSettings")); setWebhookUrl(data.webhook_url ?? ""); setWebhookSecretMasked(data.webhook_secret_masked ?? ""); setWebhookVerification(data.webhook_domain_verification ?? null); @@ -346,13 +361,14 @@ export default function SettingsWidget() { setWebhookSaveError( err instanceof Error ? err.message - : "Failed to load webhook settings", + : t("failedToLoadWebhookSettings"), ); } finally { setLoadingWebhook(false); } }; load(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- t is not stable across renders and must not retrigger this fetch }, [apiKey]); const confirmRotate = useCallback(async () => { @@ -365,21 +381,19 @@ export default function SettingsWidget() { headers: { "x-api-key": apiKey }, }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to rotate key"); + if (!res.ok) throw new Error(data.error ?? t("failedToRotateKey")); setApiKey(data.api_key); setRevealed(true); setConfirming(false); - toast.success( - "API key rotated — update any integrations using the old key.", - ); + toast.success(t("apiKeyRotatedSuccess")); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Failed to rotate key"; + const msg = err instanceof Error ? err.message : t("failedToRotateKey"); setRotateError(msg); toast.error(msg); } finally { setRotating(false); } - }, [apiKey, setApiKey]); + }, [apiKey, setApiKey, t]); const updateBrandingField = useCallback( (key: keyof typeof DEFAULT_BRANDING, value: string | null) => { @@ -396,17 +410,17 @@ export default function SettingsWidget() { const file = files[0]; if (!file) return; if (file.size > 2 * 1024 * 1024) { - toast.error("Image must be under 2MB"); + toast.error(t("imageSizeLimit")); return; } const reader = new FileReader(); reader.onload = () => { updateBrandingField("logo_url", reader.result as string); - toast.success("Logo uploaded!"); + toast.success(t("logoUploaded")); }; reader.readAsDataURL(file); }, - [updateBrandingField], + [updateBrandingField, t], ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -425,7 +439,9 @@ export default function SettingsWidget() { for (const [k, v] of Object.entries(branding)) { if (k === "logo_url") continue; if (!HEX_COLOR_REGEX.test(v as string)) { - setBrandingError(`${k} must be a valid hex color`); + setBrandingError( + t("hexColorRequired", { field: t(BRANDING_FIELD_LABEL_KEYS[k] ?? k) }), + ); return; } } @@ -438,23 +454,23 @@ export default function SettingsWidget() { body: JSON.stringify(branding), }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to save branding"); + if (!res.ok) throw new Error(data.error ?? t("failedToSaveBranding")); setBranding(data.branding_config ?? branding); - toast.success("Branding saved"); + toast.success(t("brandingSaved")); } ); - }, [apiKey, branding, executeBrandingUpdate, setBranding]); + }, [apiKey, branding, executeBrandingUpdate, setBranding, t]); const validateWebhookUrl = useCallback((url: string) => { if (!url.trim()) return null; try { const p = new URL(url); - if (p.protocol !== "https:") return "Webhook URL must use HTTPS"; + if (p.protocol !== "https:") return t("webhookUrlMustBeHttps"); return null; } catch { - return "Invalid URL (e.g. https://example.com/webhook)"; + return t("webhookUrlInvalid"); } - }, []); + }, [t]); const handleWebhookUrlChange = useCallback( (e: React.ChangeEvent) => { @@ -482,15 +498,15 @@ export default function SettingsWidget() { body: JSON.stringify({ webhook_url: optimisticUrl || undefined }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to save webhook URL"); + if (!res.ok) throw new Error(data.error ?? t("failedToSaveWebhookUrl")); setWebhookUrl(data.webhook_url ?? ""); setWebhookVerification(data.webhook_domain_verification ?? null); toast.success( - data.webhook_url ? "Webhook URL saved" : "Webhook URL cleared", + data.webhook_url ? t("webhookUrlSaved") : t("webhookUrlCleared"), ); } ); - }, [apiKey, webhookUrl, validateWebhookUrl, executeWebhookUpdate, setWebhookUrl]); + }, [apiKey, webhookUrl, validateWebhookUrl, executeWebhookUpdate, setWebhookUrl, t]); const verifyWebhookDomain = useCallback(async () => { if (!apiKey) return; @@ -503,22 +519,22 @@ export default function SettingsWidget() { }); const data = await res.json(); if (!res.ok) - throw new Error(data.error ?? "Failed to verify webhook domain"); + throw new Error(data.error ?? t("failedToVerifyDomain")); setWebhookVerification(data.webhook_domain_verification ?? null); toast.success( data.webhook_domain_verification?.status === "verified" - ? "Domain verified" - : "Domain still unverified", + ? t("domainVerified") + : t("domainStillUnverified"), ); } catch (err: unknown) { const msg = - err instanceof Error ? err.message : "Failed to verify domain"; + err instanceof Error ? err.message : t("failedToVerifyDomain"); setWebhookSaveError(msg); toast.error(msg); } finally { setVerifyingWebhookDomain(false); } - }, [apiKey]); + }, [apiKey, t]); const regenerateWebhookSecret = useCallback(async () => { if (!apiKey) return; @@ -530,20 +546,20 @@ export default function SettingsWidget() { headers: { "x-api-key": apiKey }, }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to regenerate secret"); + if (!res.ok) throw new Error(data.error ?? t("failedToRegenerateSecret")); setWebhookNewSecret(data.webhook_secret); setWebhookRevealedSecret(true); setConfirmRegenSecret(false); - toast.success("Webhook secret regenerated — update your integrations."); + toast.success(t("webhookSecretRegenerated")); } catch (err: unknown) { const msg = - err instanceof Error ? err.message : "Failed to regenerate secret"; + err instanceof Error ? err.message : t("failedToRegenerateSecret"); setWebhookSaveError(msg); toast.error(msg); } finally { setRegeneratingSecret(false); } - }, [apiKey]); + }, [apiKey, t]); const testWebhook = useCallback(async () => { if (!apiKey) return; @@ -555,16 +571,16 @@ export default function SettingsWidget() { headers: { "x-api-key": apiKey }, }); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Test webhook request failed"); - toast.success(`Test webhook sent — status ${data.status}`); + if (!res.ok) throw new Error(data.error ?? t("testWebhookRequestFailed")); + toast.success(t("testWebhookSentStatus", { status: data.status })); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Failed to test webhook"; + const msg = err instanceof Error ? err.message : t("failedToTestWebhook"); toast.error(msg); setWebhookSaveError(msg); } finally { setTestingWebhook(false); } - }, [apiKey]); + }, [apiKey, t]); const displayKey = useMemo( () => (revealed ? apiKey : mask(apiKey ?? "")), @@ -609,7 +625,36 @@ export default function SettingsWidget() { [activeTab, focusTab], ); - if (!hydrated) return null; + if (!hydrated) { + return ( +
+

+ {t("loadingSettings")} +

+ +
+ +
+ +
+
+
+
+ {Array.from({ length: navItems.length }).map((_, i) => ( + + ))} +
+
+ +
+
+
+
+ ); + } if (!apiKey) { return ( @@ -962,7 +1007,7 @@ export default function SettingsWidget() { onChange={(e) => updateBrandingField(field, e.target.value) } - aria-label={`${label} color picker`} + aria-label={t("colorPickerLabel", { label })} className="h-10 w-12 shrink-0 rounded-lg border border-[#E8E8E8] bg-white p-1 cursor-pointer" />
@@ -1327,7 +1372,16 @@ export default function SettingsWidget() { {/* Permissions Tab */} {activeTab === "permissions" && ( - +
+ +
)} {/* Danger Tab */} diff --git a/frontend/src/app/(authenticated)/settings/page.test.tsx b/frontend/src/app/(authenticated)/settings/page.test.tsx index bbf221e4..5221cb76 100644 --- a/frontend/src/app/(authenticated)/settings/page.test.tsx +++ b/frontend/src/app/(authenticated)/settings/page.test.tsx @@ -4,15 +4,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -// Import the client widget directly so the test bypasses the RSC wrapper (#1190) -import SettingsPage from "./SettingsWidget"; +import "@testing-library/jest-dom/vitest"; +// Import the client widget directly so the test bypasses the RSC wrapper (#1180/#1190) +import SettingsWidget from "./SettingsWidget"; import * as merchantStore from "@/lib/merchant-store"; import * as displayPreferences from "@/lib/display-preferences"; // ── Mocks ────────────────────────────────────────────────────────────────── vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, + useTranslations: () => (key: string, params?: Record) => + params ? `${key} ${Object.values(params).join(" ")}` : key, })); vi.mock("framer-motion", () => ({ @@ -60,6 +62,8 @@ vi.mock("@/components/EmailReceiptPreview", () => ({ vi.mock("@/components/UserPermissionsManager", () => ({ default: () =>
, })); +vi.mock("@/lib/merchant-store"); +vi.mock("@/lib/display-preferences"); global.fetch = vi.fn(); @@ -102,7 +106,7 @@ function setupMocks(apiKey = "sk_test_key") { // ── Tests ────────────────────────────────────────────────────────────────── -describe("SettingsPage", () => { +describe("SettingsWidget", () => { beforeEach(() => { vi.clearAllMocks(); setupMocks(); @@ -112,12 +116,12 @@ describe("SettingsPage", () => { describe("Rendering", () => { it("renders the page heading", async () => { - render(); + render(); await waitFor(() => expect(screen.getByText("title")).toBeInTheDocument()); }); it("renders all nav tabs", async () => { - render(); + render(); const labels = [ "navApiKeys", "navBranding", @@ -134,7 +138,7 @@ describe("SettingsPage", () => { }); it("shows API Keys panel by default", async () => { - render(); + render(); await waitFor(() => expect(screen.getByText("apiAuthTitle")).toBeInTheDocument() ); @@ -142,14 +146,15 @@ describe("SettingsPage", () => { it("shows no-API-key message when apiKey is absent", () => { vi.mocked(merchantStore).useMerchantApiKey = vi.fn(() => null); - render(); + render(); expect(screen.getByText("noApiKeyTitle")).toBeInTheDocument(); }); - it("renders nothing while store is not hydrated", () => { + it("shows a busy loading skeleton while store is not hydrated", () => { vi.mocked(merchantStore).useMerchantHydrated = vi.fn(() => false); - const { container } = render(); - expect(container.firstChild).toBeNull(); + const { container } = render(); + expect(container.firstChild).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("status")).toHaveTextContent("loadingSettings"); }); }); @@ -158,7 +163,7 @@ describe("SettingsPage", () => { describe("Tab navigation", () => { it("switches to Branding panel on click", async () => { const user = userEvent.setup(); - render(); + render(); const tabs = screen.getAllByRole("tab", { name: "navBranding" }); await user.click(tabs[0]); await waitFor(() => @@ -168,7 +173,7 @@ describe("SettingsPage", () => { it("switches to Display panel on click", async () => { const user = userEvent.setup(); - render(); + render(); const tabs = screen.getAllByRole("tab", { name: "navDisplay" }); await user.click(tabs[0]); await waitFor(() => @@ -178,7 +183,7 @@ describe("SettingsPage", () => { it("switches to Webhooks panel on click", async () => { const user = userEvent.setup(); - render(); + render(); const tabs = screen.getAllByRole("tab", { name: "navWebhooks" }); await user.click(tabs[0]); await waitFor(() => @@ -188,7 +193,7 @@ describe("SettingsPage", () => { it("switches to Permissions panel on click", async () => { const user = userEvent.setup(); - render(); + render(); const tabs = screen.getAllByRole("tab", { name: "navPermissions" }); await user.click(tabs[0]); await waitFor(() => @@ -198,7 +203,7 @@ describe("SettingsPage", () => { it("switches to Danger Zone panel on click", async () => { const user = userEvent.setup(); - render(); + render(); const tabs = screen.getAllByRole("tab", { name: "navDanger" }); await user.click(tabs[0]); await waitFor(() => @@ -212,7 +217,7 @@ describe("SettingsPage", () => { describe("API Keys tab", () => { it("reveals API key on button click", async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => screen.getByText("apiAuthTitle")); const revealBtn = screen.getByRole("button", { name: /reveal/i }); @@ -222,7 +227,7 @@ describe("SettingsPage", () => { it("shows rotate key confirmation flow", async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => screen.getByText("apiAuthTitle")); await user.click(screen.getByRole("button", { name: /rotateKeyEllipsis/i })); @@ -231,7 +236,7 @@ describe("SettingsPage", () => { it("cancels rotation when Cancel clicked", async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => screen.getByText("apiAuthTitle")); await user.click(screen.getByRole("button", { name: /rotateKeyEllipsis/i })); @@ -249,7 +254,7 @@ describe("SettingsPage", () => { return Promise.resolve({ ok: true, json: async () => ({}) }); }); - render(); + render(); await waitFor(() => screen.getByText("apiAuthTitle")); await user.click(screen.getByRole("button", { name: /rotateKeyEllipsis/i })); @@ -269,7 +274,7 @@ describe("SettingsPage", () => { describe("Branding tab", () => { async function openBranding() { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getAllByRole("tab", { name: "navBranding" })[0]); await waitFor(() => screen.getByText("checkoutBrandingTitle")); return user; @@ -277,7 +282,7 @@ describe("SettingsPage", () => { it("renders color inputs", async () => { await openBranding(); - expect(screen.getByLabelText(/primary color picker/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/colorPickerLabel primary/i)).toBeInTheDocument(); }); it("calls save branding API", async () => { @@ -306,7 +311,7 @@ describe("SettingsPage", () => { setHideCents, })); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getAllByRole("tab", { name: "navDisplay" })[0]); await waitFor(() => screen.getByText("hideTrailingCents")); @@ -320,7 +325,7 @@ describe("SettingsPage", () => { describe("Webhooks tab", () => { async function openWebhooks() { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getAllByRole("tab", { name: "navWebhooks" })[0]); await waitFor(() => screen.getByText("webhookEndpointTitle")); return user; @@ -330,7 +335,7 @@ describe("SettingsPage", () => { const user = await openWebhooks(); await user.clear(screen.getByLabelText("endpointUrl")); await user.type(screen.getByLabelText("endpointUrl"), "http://example.com"); - expect(await screen.findByText("Webhook URL must use HTTPS")).toBeInTheDocument(); + expect(await screen.findByText("webhookUrlMustBeHttps")).toBeInTheDocument(); }); it("calls save webhook API", async () => { diff --git a/frontend/src/app/(authenticated)/settings/page.tsx b/frontend/src/app/(authenticated)/settings/page.tsx index 795fc06d..28070bb0 100644 --- a/frontend/src/app/(authenticated)/settings/page.tsx +++ b/frontend/src/app/(authenticated)/settings/page.tsx @@ -1,1172 +1,15 @@ -"use client"; +import { getTranslations } from "next-intl/server"; +import SettingsWidget from "./SettingsWidget"; -import dynamic from "next/dynamic"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, -} from "react"; -import { useTranslations } from "next-intl"; -import { useOptimisticUpdate } from "@/hooks/useOptimisticUpdate"; -import { useDropzone } from "react-dropzone"; -import Link from "next/link"; -import Image from "next/image"; -import CopyButton from "@/components/CopyButton"; -import { toast } from "sonner"; -import { - useHydrateMerchantStore, - useMerchantApiKey, - useMerchantHydrated, - useSetMerchantApiKey, -} from "@/lib/merchant-store"; -import { useDisplayPreferences } from "@/lib/display-preferences"; -import WebhookHealthIndicator from "@/components/WebhookHealthIndicator"; -import DangerZone from "@/components/DangerZone"; -import { EmailReceiptPreview } from "@/components/EmailReceiptPreview"; -import SettingsPanelSkeleton from "@/components/SettingsPanelSkeleton"; -import { Spinner } from "@/components/ui/Spinner"; -import { - getNextSettingsTab, - getSettingsPanelDomId, - getSettingsTabDomId, - type SettingsTab, -} from "./accessibility"; +export async function generateMetadata() { + const t = await getTranslations("settingsPage"); -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; -const UserPermissionsManager = dynamic( - () => import("@/components/UserPermissionsManager"), - { - ssr: false, - loading: () => , - }, -); -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", - logo_url: null as string | null, -}; + return { + title: `${t("title")} — PLUTO`, + description: t("description"), + }; +} -/** - * RSC page — mounts the interactive client SettingsWidget. - * Any server-only work (e.g. reading cookies, fetching public config) - * can be added here and passed down as props to SettingsWidget without - * bloating the client bundle. - */ export default function SettingsPage() { - const t = useTranslations("settingsPage"); - const navItems = useMemo(() => buildNavItems(t), [t]); - const apiKey = useMerchantApiKey(); - const hydrated = useMerchantHydrated(); - const setApiKey = useSetMerchantApiKey(); - const [revealed, setRevealed] = useState(false); - const [confirming, setConfirming] = useState(false); - const [rotating, setRotating] = useState(false); - const [rotateError, setRotateError] = useState(null); - const [activeTab, setActiveTab] = useState("api"); - const { hideCents, setHideCents } = useDisplayPreferences(); - const { - state: branding, - setState: setBranding, - isPending: savingBranding, - executeUpdate: executeBrandingUpdate, - } = useOptimisticUpdate(DEFAULT_BRANDING); - const [brandingError, setBrandingError] = useState(null); - const [loadingBranding, setLoadingBranding] = useState(false); - const [isPreviewOpen, setIsPreviewOpen] = useState(false); - const { - state: webhookUrl, - setState: setWebhookUrl, - isPending: savingWebhook, - executeUpdate: executeWebhookUpdate, - } = useOptimisticUpdate(""); - const [webhookSecretMasked, setWebhookSecretMasked] = useState(""); - const [webhookNewSecret, setWebhookNewSecret] = useState(null); - const [webhookUrlError, setWebhookUrlError] = useState(null); - const [webhookSaveError, setWebhookSaveError] = useState(null); - const [loadingWebhook, setLoadingWebhook] = useState(false); - const [regeneratingSecret, setRegeneratingSecret] = useState(false); - const [confirmRegenSecret, setConfirmRegenSecret] = useState(false); - const [webhookRevealedSecret, setWebhookRevealedSecret] = useState(false); - const [testingWebhook, setTestingWebhook] = useState(false); - const [webhookVerification, setWebhookVerification] = - useState(null); - const [verifyingWebhookDomain, setVerifyingWebhookDomain] = useState(false); - const desktopTabRefs = useRef>>({}); - const mobileTabRefs = useRef>>({}); - - useHydrateMerchantStore(); - - useEffect(() => { - if (!apiKey) return; - const load = async () => { - setLoadingBranding(true); - 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) { - setBrandingError( - err instanceof Error ? err.message : "Failed to load branding", - ); - } finally { - setLoadingBranding(false); - } - }; - load(); - }, [apiKey]); - - useEffect(() => { - if (!apiKey) return; - const load = async () => { - setLoadingWebhook(true); - try { - const res = await fetch(`${API_URL}/api/webhook-settings`, { - headers: { "x-api-key": apiKey }, - }); - const data = await res.json(); - if (!res.ok) - throw new Error(data.error ?? "Failed to load webhook settings"); - setWebhookUrl(data.webhook_url ?? ""); - setWebhookSecretMasked(data.webhook_secret_masked ?? ""); - setWebhookVerification(data.webhook_domain_verification ?? null); - } catch (err: unknown) { - setWebhookSaveError( - err instanceof Error - ? err.message - : "Failed to load webhook settings", - ); - } finally { - setLoadingWebhook(false); - } - }; - load(); - }, [apiKey]); - - const confirmRotate = useCallback(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"); - setApiKey(data.api_key); - setRevealed(true); - 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); - } - }, [apiKey, setApiKey]); - - const updateBrandingField = useCallback( - (key: keyof typeof DEFAULT_BRANDING, value: string | null) => { - setBranding((c) => ({ - ...c, - [key]: key === "logo_url" ? value : normalizeHexInput(value as string), - })); - }, - [], - ); - - const onDrop = useCallback( - (files: File[]) => { - const file = files[0]; - if (!file) return; - if (file.size > 2 * 1024 * 1024) { - toast.error("Image must be under 2MB"); - return; - } - const reader = new FileReader(); - reader.onload = () => { - updateBrandingField("logo_url", reader.result as string); - toast.success("Logo uploaded!"); - }; - reader.readAsDataURL(file); - }, - [updateBrandingField], - ); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - accept: { - "image/png": [".png"], - "image/jpeg": [".jpg", ".jpeg"], - "image/svg+xml": [".svg"], - }, - multiple: false, - }); - - const saveBranding = useCallback(async () => { - if (!apiKey) return; - setBrandingError(null); - for (const [k, v] of Object.entries(branding)) { - if (k === "logo_url") continue; - if (!HEX_COLOR_REGEX.test(v as string)) { - setBrandingError(`${k} must be a valid hex color`); - return; - } - } - await executeBrandingUpdate( - (current) => current, // optimistically keep current branding - async () => { - 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"); - } - ); - }, [apiKey, branding, executeBrandingUpdate, setBranding]); - - const validateWebhookUrl = useCallback((url: string) => { - if (!url.trim()) return null; - try { - const p = new URL(url); - if (p.protocol !== "https:") return "Webhook URL must use HTTPS"; - return null; - } catch { - return "Invalid URL (e.g. https://example.com/webhook)"; - } - }, []); - - const handleWebhookUrlChange = useCallback( - (e: React.ChangeEvent) => { - setWebhookUrl(() => e.target.value); - setWebhookUrlError(validateWebhookUrl(e.target.value)); - }, - [validateWebhookUrl, setWebhookUrl], - ); - - const saveWebhookUrl = useCallback(async () => { - if (!apiKey) return; - const err = validateWebhookUrl(webhookUrl); - if (err) { - setWebhookUrlError(err); - return; - } - setWebhookSaveError(null); - const optimisticUrl = webhookUrl.trim(); - await executeWebhookUpdate( - () => optimisticUrl, // optimistically show the trimmed URL immediately - async () => { - const res = await fetch(`${API_URL}/api/webhook-settings`, { - method: "PUT", - headers: { "Content-Type": "application/json", "x-api-key": apiKey }, - body: JSON.stringify({ webhook_url: optimisticUrl || undefined }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to save webhook URL"); - setWebhookUrl(data.webhook_url ?? ""); - setWebhookVerification(data.webhook_domain_verification ?? null); - toast.success( - data.webhook_url ? "Webhook URL saved" : "Webhook URL cleared", - ); - } - ); - }, [apiKey, webhookUrl, validateWebhookUrl, executeWebhookUpdate, setWebhookUrl]); - - const verifyWebhookDomain = useCallback(async () => { - if (!apiKey) return; - setVerifyingWebhookDomain(true); - setWebhookSaveError(null); - try { - const res = await fetch(`${API_URL}/api/webhook-settings/verify`, { - method: "POST", - headers: { "x-api-key": apiKey }, - }); - const data = await res.json(); - if (!res.ok) - throw new Error(data.error ?? "Failed to verify webhook domain"); - setWebhookVerification(data.webhook_domain_verification ?? null); - toast.success( - data.webhook_domain_verification?.status === "verified" - ? "Domain verified" - : "Domain still unverified", - ); - } catch (err: unknown) { - const msg = - err instanceof Error ? err.message : "Failed to verify domain"; - setWebhookSaveError(msg); - toast.error(msg); - } finally { - setVerifyingWebhookDomain(false); - } - }, [apiKey]); - - const regenerateWebhookSecret = useCallback(async () => { - if (!apiKey) return; - setRegeneratingSecret(true); - setWebhookSaveError(null); - try { - const res = await fetch(`${API_URL}/api/regenerate-webhook-secret`, { - method: "POST", - headers: { "x-api-key": apiKey }, - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to regenerate secret"); - setWebhookNewSecret(data.webhook_secret); - setWebhookRevealedSecret(true); - setConfirmRegenSecret(false); - toast.success("Webhook secret regenerated — update your integrations."); - } catch (err: unknown) { - const msg = - err instanceof Error ? err.message : "Failed to regenerate secret"; - setWebhookSaveError(msg); - toast.error(msg); - } finally { - setRegeneratingSecret(false); - } - }, [apiKey]); - - const testWebhook = useCallback(async () => { - if (!apiKey) return; - setTestingWebhook(true); - setWebhookSaveError(null); - try { - const res = await fetch(`${API_URL}/api/webhooks/test`, { - method: "POST", - headers: { "x-api-key": apiKey }, - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Test webhook request failed"); - toast.success(`Test webhook sent — status ${data.status}`); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "Failed to test webhook"; - toast.error(msg); - setWebhookSaveError(msg); - } finally { - setTestingWebhook(false); - } - }, [apiKey]); - - const displayKey = useMemo( - () => (revealed ? apiKey : mask(apiKey ?? "")), - [revealed, apiKey], - ); - const lowContrastWarning = useMemo( - () => - contrastRatio(branding.primary_color, branding.background_color) < 4.5 || - contrastRatio(branding.secondary_color, branding.background_color) < 3, - [branding.primary_color, branding.secondary_color, branding.background_color], - ); - const isVerified = useMemo( - () => webhookVerification?.status === "verified", - [webhookVerification], - ); - - const focusTab = useCallback((tab: SettingsTab, variant: "desktop" | "mobile") => { - const refMap = variant === "desktop" ? desktopTabRefs.current : mobileTabRefs.current; - refMap[tab]?.focus(); - }, []); - - const handleTabKeyDown = useCallback( - (event: ReactKeyboardEvent, variant: "desktop" | "mobile") => { - const supportedKeys = [ - "ArrowLeft", - "ArrowRight", - "ArrowUp", - "ArrowDown", - "Home", - "End", - ]; - - if (!supportedKeys.includes(event.key)) { - return; - } - - event.preventDefault(); - const nextTab = getNextSettingsTab(activeTab, event.key); - setActiveTab(nextTab); - focusTab(nextTab, variant); - }, - [activeTab, focusTab], - ); - - if (!hydrated) return null; - - if (!apiKey) { - return ( -
-
-

- {t("eyebrow")} -

-

- {t("title")} -

-
-
-

{t("noApiKeyTitle")}

-

- {t("noApiKeyDescription")} -

- - {t("registerAsMerchant")} - -
-
- ); - } - - return ( -
- {/* Page header */} -
-

- {t("eyebrowAccount")} -

-

- {t("title")} -

-

- {t("description")} -

-
- - {/* Two-column layout */} -
- {/* Left nav */} - - - {/* Mobile tab bar */} -
- {navItems.map((item) => ( - - ))} -
- - {/* Right content panel */} -
- {/* API Keys Tab */} - {activeTab === "api" && ( -
-
-

- {t("apiAuthTitle")} -

-

- {t("apiAuthDescription")} -

-
- -
-
- - -
-
- - {displayKey} - - {revealed && } -
-

- {t("apiKeyHeaderHintPrefix")}{" "} - x-api-key{" "} - {t("apiKeyHeaderHintSuffix")} -

-

- {revealed ? t("apiKeyVisible") : t("apiKeyHidden")} -

-
- -
- -
-
-

- {t("rotateApiKeyTitle")} -

-

- {t("rotateApiKeyDescription")} -

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

- {t("confirmAction")} -

-

- {t("rotateKeyWarning")} -

-
- - -
-
- )} -
-
- )} - - {/* Branding Tab */} - {activeTab === "branding" && loadingBranding && ( -
- -
- )} - {activeTab === "branding" && !loadingBranding && ( -
-
-

- {t("checkoutBrandingTitle")} -

-

- {t("checkoutBrandingDescription")} -

-
- - {/* Logo upload */} -
- -
- - {branding.logo_url ? ( -
- Logo - - {t("clickOrDragToReplace")} - -
- ) : ( -
-
- - - -
-

- {isDragActive ? t("dropHere") : t("uploadLogo")} -

-

- {t("logoFormats")} -

-
- )} -
- {branding.logo_url && ( - - )} -
- - {/* Color pickers */} -
- - {( - [ - ["primary_color", t("primary")], - ["secondary_color", t("secondary")], - ["background_color", t("background")], - ] as const - ).map(([field, label]) => ( -
- - updateBrandingField(field, e.target.value) - } - aria-label={`${label} color picker`} - className="h-10 w-12 shrink-0 rounded-lg border border-[#E8E8E8] bg-white p-1 cursor-pointer" - /> -
- - - updateBrandingField(field, e.target.value) - } - className="w-full rounded-lg border border-[#E8E8E8] bg-[#F9F9F9] px-3 py-2 font-mono text-sm text-[#0A0A0A] focus:border-[#0A0A0A] focus:outline-none" - /> -
-
- ))} -
- - {/* Preview */} -
-
-

- {t("preview")} -

-
-
-
-

- {t("sampleCheckout")} -

- -
-
-
- - {brandingError && ( -
- {brandingError} -
- )} - {lowContrastWarning && ( -
- {t("lowContrastWarning")} -
- )} - -
- - -
-
- )} - - {/* Display Tab */} - {activeTab === "display" && ( -
-
-

- {t("displayPreferencesTitle")} -

-

- {t("displayPreferencesDescription")} -

-
-
- -
-
- )} - - {/* Webhooks Tab */} - {activeTab === "webhooks" && loadingWebhook && ( -
- -
- )} - {activeTab === "webhooks" && !loadingWebhook && ( -
-
-
-

- {t("webhookEndpointTitle")} -

-

- {t("webhookEndpointDescription")} -

-
- {webhookUrl && ( -
- - - {isVerified ? t("verified") : t("unverified")} - -
- )} -
- - {webhookSaveError && ( -
- {webhookSaveError} -
- )} - -
- - - {webhookUrlError && ( - - )} -
- - -
-
- - {webhookUrl && webhookVerification && ( -
-

- {t("domainVerification")} -

-

- {t("hostTokenAt")}{" "} - - {webhookVerification.verification_file_url} - -

-
- - {webhookVerification.verification_token ?? "—"} - - {webhookVerification.verification_token && ( - - )} -
- -
- )} - -
- -
-
-

- {t("signingSecretTitle")} -

-

- {t("signingSecretDescriptionPrefix")}{" "} - Pluto-Signature{" "} - {t("signingSecretDescriptionSuffix")} -

-
-
- - {webhookNewSecret - ? webhookRevealedSecret - ? webhookNewSecret - : "•".repeat(webhookNewSecret.length) - : webhookSecretMasked || "—"} - - {webhookNewSecret && ( - - )} - {webhookNewSecret && webhookRevealedSecret && ( - - )} -
- {webhookNewSecret && ( -
- {webhookRevealedSecret - ? t("webhookSecretVisible") - : t("webhookSecretHidden")} -
- )} - {!confirmRegenSecret ? ( - - ) : ( -
-

- {t("confirmAction")} -

-

- {t("regenerateSecretWarning")} -

-
- - -
-
- )} -
-
- )} - - {/* Permissions Tab */} - {activeTab === "permissions" && ( -
- -
- )} - - {/* Danger Tab */} - {activeTab === "danger" && ( -
-
-

- {t("navDanger")} -

-

- {t("dangerZoneDescription")} -

-
- -
- )} -
- {/* end right panel */} -
- {/* end two-column */} - - setIsPreviewOpen(false)} - branding={branding} - apiKey={apiKey} - apiUrl={API_URL} - /> -
- ); + return ; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 62d34c30..cceab4f6 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,4 @@ import "./globals.css"; -import { Inconsolata, Space_Mono } from "next/font/google"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import ThemeProvider from "@/components/ThemeProvider"; @@ -11,10 +10,6 @@ import { WalletContextProvider } from "@/lib/wallet-context"; import { DisplayPreferencesProvider } from "@/lib/display-preferences"; import { Metadata, Viewport } from "next"; -const sansFont = Inconsolata({ subsets: ["latin"], weight: ["400", "500", "600", "700"], variable: "--font-inconsolata", display: "swap" }); -const displayFont = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-space-mono", display: "swap" }); -const monoFont = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-mono", display: "swap" }); - export const metadata: Metadata = { title: "PLUTO | Web3 Payments", description: "The Hub for Decentralized Commerce on Stellar.", @@ -42,7 +37,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo - + diff --git a/frontend/src/app/pay/[id]/page.tsx b/frontend/src/app/pay/[id]/page.tsx index 4c15c3a4..b065bc49 100644 --- a/frontend/src/app/pay/[id]/page.tsx +++ b/frontend/src/app/pay/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; @@ -23,10 +23,18 @@ import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { QRCodeSVG } from "qrcode.react"; import { localeToLanguageTag } from "@/i18n/config"; -import { PaymentSuccessAnimation } from "@/components/PaymentSuccessAnimation"; import { useCheckoutPresence } from "@/lib/useCheckoutPresence"; import { Modal } from "@/components/ui/Modal"; +// Lazy-load PaymentSuccessAnimation so its dependencies (framer-motion success +// variants, canvas-confetti) are excluded from the initial checkout bundle and +// only fetched the first time a payment actually succeeds. (#1179) +const PaymentSuccessAnimation = lazy(() => + import("@/components/PaymentSuccessAnimation").then((m) => ({ + default: m.PaymentSuccessAnimation, + })) +); + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "testnet"; const EXPLORER_BASE = NETWORK === "public" @@ -161,6 +169,9 @@ export default function PaymentPage() { const [actionError, setActionError] = useState(null); const [showQrModal, setShowQrModal] = useState(false); const [isOptimisticSuccess, setIsOptimisticSuccess] = useState(false); + // Tracks whether the success animation has ever been needed, so its lazy + // chunk is only fetched on first success rather than on initial page load. + const [hasSucceededOnce, setHasSucceededOnce] = useState(false); const [isDownloadingReceipt, setIsDownloadingReceipt] = useState(false); const [isPayModalOpen, setIsPayModalOpen] = useState(false); const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); @@ -188,6 +199,10 @@ export default function PaymentPage() { } }, [payment]); + useEffect(() => { + if (isOptimisticSuccess) setHasSucceededOnce(true); + }, [isOptimisticSuccess]); + useEffect(() => { const controller = new AbortController(); const load = async () => { @@ -386,14 +401,18 @@ export default function PaymentPage() { return ( <> - setIsOptimisticSuccess(false)} - amount={payment?.amount != null ? String(payment.amount) : undefined} - asset={payment?.asset} - txId={payment?.tx_id ?? undefined} - isOptimistic={payment?.status !== "confirmed" && payment?.status !== "completed"} - /> + {hasSucceededOnce && ( + + setIsOptimisticSuccess(false)} + amount={payment?.amount != null ? String(payment.amount) : undefined} + asset={payment?.asset} + txId={payment?.tx_id ?? undefined} + isOptimistic={payment?.status !== "confirmed" && payment?.status !== "completed"} + /> + + )} {isProcessing && !isOptimisticSuccess && ( @@ -528,7 +547,7 @@ export default function PaymentPage() { {t("completePayment")}

- {payment.description ?? t("paymentRequest")} + {t("paymentRequest")}

{activeProvider ? ( @@ -589,6 +608,7 @@ export default function PaymentPage() {
+ )} {pathQuoteLoading &&

Checking payment routes…

} {pathQuoteError &&

{pathQuoteError}

} @@ -707,4 +727,3 @@ export default function PaymentPage() { ); } - diff --git a/frontend/src/components/CreatePaymentForm.tsx b/frontend/src/components/CreatePaymentForm.tsx index 1334473b..1bfad7cc 100644 --- a/frontend/src/components/CreatePaymentForm.tsx +++ b/frontend/src/components/CreatePaymentForm.tsx @@ -316,7 +316,7 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) { {t("shareLink")} -
+ {/* Reset link */} diff --git a/frontend/src/components/MultisigApprovalModal.tsx b/frontend/src/components/MultisigApprovalModal.tsx index 6e6365e2..70a54354 100644 --- a/frontend/src/components/MultisigApprovalModal.tsx +++ b/frontend/src/components/MultisigApprovalModal.tsx @@ -1,10 +1,10 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; -import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; +import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion"; import { useMultisigState, useMultisigActions } from "@/lib/multisig-context"; import { toast } from "sonner"; -import { CopyButton } from "@/components/CopyButton"; +import CopyButton from "@/components/CopyButton"; import { errorMessageVariants, } from "@/lib/network-animations"; @@ -15,7 +15,7 @@ const backdropVariants = { exit: { opacity: 0, transition: { duration: 0.3 } }, }; -const modalVariants = { +const modalVariants: Variants = { hidden: { scale: 0.85, opacity: 0, y: 30 }, visible: { scale: 1, diff --git a/frontend/src/components/NetworkStatusIndicator.tsx b/frontend/src/components/NetworkStatusIndicator.tsx index 2e29be32..3154fbcd 100644 --- a/frontend/src/components/NetworkStatusIndicator.tsx +++ b/frontend/src/components/NetworkStatusIndicator.tsx @@ -148,8 +148,8 @@ export const NetworkStatusIndicator: React.FC< tabIndex={enableKeyboardNavigation ? 0 : undefined} onHoverStart={() => setIsHovered(true)} onHoverEnd={() => setIsHovered(false)} - onFocusStart={() => setIsFocused(true)} - onFocusEnd={() => setIsFocused(false)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} > {/* Background hover effect */} {enableMicroInteractions && ( diff --git a/frontend/src/components/PaymentMetrics.tsx b/frontend/src/components/PaymentMetrics.tsx index f33af566..c2f7e58f 100644 --- a/frontend/src/components/PaymentMetrics.tsx +++ b/frontend/src/components/PaymentMetrics.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, useCallback, useMemo, useReducer } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, type Variants } from "framer-motion"; import * as Recharts from "recharts"; const { CartesianGrid, @@ -134,7 +134,7 @@ function metricsReducer(state: MetricsState, action: MetricsAction): MetricsStat // ── Animation Variants (Issue #784: Framer Motion animations) ──────────────── -const containerVariants = { +const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, @@ -145,7 +145,7 @@ const containerVariants = { }, }; -const cardVariants = { +const cardVariants: Variants = { hidden: { opacity: 0, y: 20, scale: 0.95 }, visible: { opacity: 1, @@ -159,7 +159,7 @@ const cardVariants = { }, }; -const chartVariants = { +const chartVariants: Variants = { hidden: { opacity: 0, scale: 0.98 }, visible: { opacity: 1, @@ -173,12 +173,12 @@ const chartVariants = { }, }; -const buttonVariants = { +const buttonVariants: Variants = { hover: { scale: 1.05, transition: { duration: 0.2 } }, tap: { scale: 0.95, transition: { duration: 0.1 } }, }; -const assetToggleVariants = { +const assetToggleVariants: Variants = { hidden: { opacity: 0, scale: 0.8 }, visible: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.8, transition: { duration: 0.2 } }, diff --git a/frontend/src/components/PaymentSuccessAnimation.tsx b/frontend/src/components/PaymentSuccessAnimation.tsx index 057b278e..d8b6aad9 100644 --- a/frontend/src/components/PaymentSuccessAnimation.tsx +++ b/frontend/src/components/PaymentSuccessAnimation.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useCallback, useEffect, useRef, useReducer } from "react"; -import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion"; +import React, { useCallback, useEffect, useRef, useReducer, useState } from "react"; +import { motion, AnimatePresence, type Variants } from "framer-motion"; import confetti from "canvas-confetti"; import { useTranslations } from "next-intl"; @@ -88,6 +88,23 @@ const initialState: AnimationState = { announcementText: "", }; +function usePrefersReducedMotion() { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updatePreference = () => setPrefersReducedMotion(mediaQuery.matches); + + updatePreference(); + mediaQuery.addEventListener("change", updatePreference); + return () => mediaQuery.removeEventListener("change", updatePreference); + }, []); + + return prefersReducedMotion; +} + function animationReducer(state: AnimationState, action: AnimationAction): AnimationState { switch (action.type) { case "RESET": @@ -130,7 +147,7 @@ export const PaymentSuccessAnimation: React.FC = ( }) => { const t = useTranslations(); // Respect OS-level "prefer reduced motion" setting (#980) - const prefersReducedMotion = useReducedMotion(); + const prefersReducedMotion = usePrefersReducedMotion(); const hasTriggeredConfettiRef = useRef(false); const completeTimerRef = useRef | null>(null); const closeButtonRef = useRef(null); diff --git a/frontend/src/components/PortfolioChartWidget.tsx b/frontend/src/components/PortfolioChartWidget.tsx index 0921c6c2..17cf8de7 100644 --- a/frontend/src/components/PortfolioChartWidget.tsx +++ b/frontend/src/components/PortfolioChartWidget.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useCallback, useMemo } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion, AnimatePresence, type Variants } from 'framer-motion'; import { PieChart, Pie, @@ -107,7 +107,7 @@ export function PortfolioChartWidget({ ); // Container animation variants - const containerVariants = { + const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, @@ -118,7 +118,7 @@ export function PortfolioChartWidget({ }, }; - const itemVariants = { + const itemVariants: Variants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, @@ -228,7 +228,7 @@ export function PortfolioChartWidget({ /> { - const asset = entry.payload.payload as PortfolioAsset; + const asset = (entry as unknown as { payload: { payload: PortfolioAsset } }).payload.payload; return `${asset.symbol} (${asset.percentage.toFixed(1)}%)`; }} wrapperStyle={{ diff --git a/frontend/src/components/RealTimeBalanceSync.tsx b/frontend/src/components/RealTimeBalanceSync.tsx index 965c45c1..22482dea 100644 --- a/frontend/src/components/RealTimeBalanceSync.tsx +++ b/frontend/src/components/RealTimeBalanceSync.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useId } from "react"; -import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; +import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion"; import { useBalanceSync } from "@/hooks/useBalanceSync"; interface RealTimeBalanceSyncProps { @@ -13,7 +13,7 @@ interface RealTimeBalanceSyncProps { className?: string; } -const containerVariants = { +const containerVariants: Variants = { hidden: { opacity: 0, y: -8 }, visible: { opacity: 1, @@ -22,7 +22,7 @@ const containerVariants = { }, }; -const listVariants = { +const listVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, @@ -30,7 +30,7 @@ const listVariants = { }, }; -const itemVariants = { +const itemVariants: Variants = { hidden: { opacity: 0, x: -12 }, visible: { opacity: 1, diff --git a/frontend/src/components/RecentPayments.tsx b/frontend/src/components/RecentPayments.tsx index 74c8de07..45afa334 100644 --- a/frontend/src/components/RecentPayments.tsx +++ b/frontend/src/components/RecentPayments.tsx @@ -731,7 +731,7 @@ export const RecentPayments = memo(function RecentPayments({ - )) + )) )} @@ -802,9 +802,9 @@ export const RecentPayments = memo(function RecentPayments({ /> ); -} - -function Chip({ label, onRemove }: { label: string; onRemove: () => void }) { +}); + +function Chip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( {label} @@ -830,5 +830,4 @@ function Chip({ label, onRemove }: { label: string; onRemove: () => void }) { ); } -export default RecentPayments; - \ No newline at end of file +export default RecentPayments; diff --git a/frontend/src/components/SupportPanel.tsx b/frontend/src/components/SupportPanel.tsx index 7a32b6f2..976d5623 100644 --- a/frontend/src/components/SupportPanel.tsx +++ b/frontend/src/components/SupportPanel.tsx @@ -156,7 +156,7 @@ export function SupportPanel() { className="flex items-center gap-2" > {t("loadingBalance")} -