Skip to content

Commit d73f535

Browse files
feat(frontend): transaction toasts, offline PWA, pt i18n, and Sentry breadcrumbs
Closes #810, #811, #812, #813. - #810: wire Sentry breadcrumbs into the existing TransactionToastHandler so the multi-step (awaiting signature -> submitting -> confirmed/failed) lifecycle is also observable in telemetry, alongside the Stellar Expert explorer link. - #811: add an offline-first service worker (public/sw.js) with stale-while- revalidate static caching, network-first task/API caching, and a graceful /offline fallback; register it in ClientInit (production only) and add app/offline.tsx. - #812: add the requested Portuguese locale (i18n/translations/pt.json) and wire pt throughout the i18n core (locales, flags, selector metadata, detect map). - #813: wrap next.config.ts with withSentryConfig so client/server/edge bundles are instrumented; add wallet + transaction lifecycle breadcrumbs helper (src/lib/errors/breadcrumbs.ts) and wire it into the toast handler. Tests: full frontend jest suite passes (2257 tests / 210 suites). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 3874e2e commit d73f535

10 files changed

Lines changed: 444 additions & 3 deletions

File tree

frontend/app/ClientInit.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ export function ClientInit() {
88
useEffect(() => {
99
// Sentry is initialized in sentry.client.config.ts
1010
instrumentFetch();
11+
12+
// Issue #811: register the offline-first service worker (production only so
13+
// dev HMR and local iteration are never byte-cached). Workbox caching is
14+
// implemented directly in public/sw.js (static assets, fonts, and read-only
15+
// task queries) with a graceful /offline fallback.
16+
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
17+
navigator.serviceWorker.register('/sw.js').catch((err) => {
18+
console.warn('Service worker registration failed:', err);
19+
});
20+
}
1121
}, []);
1222
return null;
1323
}

frontend/app/offline.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export const metadata = {
2+
title: "Offline — SoroTask",
3+
description: "You are offline. Reconnect to keep browsing the SoroTask dashboard.",
4+
};
5+
6+
export default function OfflinePage() {
7+
return (
8+
<main className="flex min-h-screen flex-col items-center justify-center bg-neutral-900 px-6 text-center text-neutral-100">
9+
<div aria-hidden className="mb-6 text-5xl">
10+
📡
11+
</div>
12+
<h1 className="mb-3 text-2xl font-bold">You&apos;re offline</h1>
13+
<p className="max-w-md text-sm text-neutral-300">
14+
The SoroTask dashboard needs a network connection to load the latest data.
15+
Check your connection and try again.
16+
</p>
17+
<button
18+
type="button"
19+
onClick={() => window.location.reload()}
20+
className="mt-6 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
21+
>
22+
Retry
23+
</button>
24+
</main>
25+
);
26+
}

frontend/i18n/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
import en from './translations/en.json';
77
import es from './translations/es.json';
88
import fr from './translations/fr.json';
9+
import pt from './translations/pt.json';
910
import zh from './translations/zh.json';
1011
import de from './translations/de.json';
1112

12-
export type Locale = 'en' | 'es' | 'fr' | 'zh' | 'de';
13+
export type Locale = 'en' | 'es' | 'fr' | 'pt' | 'zh' | 'de';
1314

14-
export const SUPPORTED_LOCALES: Locale[] = ['en', 'es', 'fr', 'zh', 'de'];
15+
export const SUPPORTED_LOCALES: Locale[] = ['en', 'es', 'fr', 'pt', 'zh', 'de'];
1516

1617
export const DEFAULT_LOCALE: Locale = 'en';
1718

@@ -20,6 +21,7 @@ const translations: Record<Locale, typeof en> = {
2021
en,
2122
es,
2223
fr,
24+
pt,
2325
zh,
2426
de,
2527
};
@@ -32,6 +34,7 @@ export function getLocaleName(locale: Locale): string {
3234
en: 'English',
3335
es: 'Español (Spanish)',
3436
fr: 'Français (French)',
37+
pt: 'Português (Portuguese)',
3538
zh: '中文 (Chinese)',
3639
de: 'Deutsch (German)',
3740
};
@@ -46,6 +49,7 @@ export function getLocaleFlag(locale: Locale): string {
4649
en: '🇺🇸',
4750
es: '🇪🇸',
4851
fr: '🇫🇷',
52+
pt: '🇵🇹',
4953
zh: '🇨🇳',
5054
de: '🇩🇪',
5155
};
@@ -68,6 +72,7 @@ export function detectLocale(): Locale {
6872
en: 'en',
6973
es: 'es',
7074
fr: 'fr',
75+
pt: 'pt',
7176
zh: 'zh',
7277
de: 'de',
7378
};
@@ -205,6 +210,12 @@ export function getAvailableLocales(): Array<{
205210
flag: '🇫🇷',
206211
nativeName: 'Français',
207212
},
213+
{
214+
code: 'pt',
215+
name: 'Portuguese',
216+
flag: '🇵🇹',
217+
nativeName: 'Português',
218+
},
208219
{
209220
code: 'zh',
210221
name: 'Chinese',

frontend/i18n/translations/pt.json

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
{
2+
"common": {
3+
"app_name": "SoroTask",
4+
"connect_wallet": "Conectar Carteira",
5+
"close": "Fechar",
6+
"cancel": "Cancelar",
7+
"save": "Salvar",
8+
"edit": "Editar",
9+
"delete": "Excluir",
10+
"loading": "Carregando...",
11+
"error": "Erro",
12+
"success": "Sucesso",
13+
"warning": "Aviso",
14+
"no_data": "Nenhum dado disponível"
15+
},
16+
"calendar": {
17+
"title": "Calendário de Agendamento de Tarefas",
18+
"today": "Hoje",
19+
"previous_month": "Mês anterior",
20+
"next_month": "Próximo mês",
21+
"timezone": "Fuso horário",
22+
"legend": {
23+
"today": "Hoje",
24+
"task_with_deadline": "Tarefa com prazo",
25+
"multiple_tasks": "Várias tarefas na mesma data",
26+
"past_deadline": "Prazo expirado"
27+
},
28+
"selected_date_title": "Tarefas em {date}",
29+
"no_tasks_scheduled": "Nenhuma tarefa agendada",
30+
"schedule_calendar": "Calendário de Agendamento"
31+
},
32+
"calendar_day": {
33+
"more_button": "+{count} mais",
34+
"tasks_label": "{count} tarefa{plural}"
35+
},
36+
"dense_popover": {
37+
"tasks_scheduled": "{count} tarefa{plural} agendada(s)",
38+
"click_task_to_view": "Clique na tarefa para ver os detalhes"
39+
},
40+
"task_detail": {
41+
"title": "Detalhes da Tarefa",
42+
"status": "Status",
43+
"contract_address": "Endereço do Contrato",
44+
"description": "Descrição",
45+
"schedule_settings": "Configurações de Agendamento",
46+
"execution_interval": "Intervalo de Execução",
47+
"gas_balance": "Saldo de Gás",
48+
"next_execution": "Próxima Execução",
49+
"deadline": "Prazo",
50+
"due_date": "Data de Vencimento",
51+
"time_until_deadline": "Tempo até o Prazo",
52+
"due_today": "Vence Hoje!",
53+
"overdue_by": "Atrasado por {days} dia{plural}",
54+
"days_remaining": "{days} dia{plural} restante(s)",
55+
"created": "Criado",
56+
"timezone_label": "Fuso horário",
57+
"tags": "Tags",
58+
"edit_task": "Editar Tarefa",
59+
"view_logs": "Ver Logs",
60+
"status_pending": "Pendente",
61+
"status_active": "Ativa",
62+
"status_completed": "Concluída",
63+
"status_failed": "Falhou",
64+
"status_paused": "Pausada"
65+
},
66+
"forms": {
67+
"create_automation_task": "Criar Tarefa de Automação",
68+
"your_tasks": "Suas Tarefas",
69+
"target_contract_address": "Endereço do Contrato Alvo",
70+
"contract_placeholder": "C...",
71+
"function_name": "Nome da Função",
72+
"function_placeholder": "harvest_yield",
73+
"interval_seconds": "Intervalo (segundos)",
74+
"interval_placeholder": "3600",
75+
"gas_balance_xlm": "Saldo de Gás (XLM)",
76+
"gas_balance_placeholder": "10",
77+
"register_task": "Registrar Tarefa",
78+
"no_tasks_registered_yet": "Nenhuma tarefa registrada ainda."
79+
},
80+
"execution_logs": {
81+
"title": "Logs de Execução",
82+
"task_id": "ID da Tarefa",
83+
"target": "Alvo",
84+
"keeper": "Guardião",
85+
"status": "Status",
86+
"timestamp": "Data/Hora",
87+
"status_success": "Sucesso",
88+
"mins_ago": "há {mins} min"
89+
},
90+
"formatting": {
91+
"hours_short": "h",
92+
"seconds_short": "s",
93+
"interval_display": "{hours}h ({seconds} segundos)",
94+
"xlm_unit": "XLM"
95+
}
96+
}

frontend/next.config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextConfig } from "next";
2+
import { withSentryConfig } from "@sentry/nextjs";
23

34
const nextConfig: NextConfig = {
45
experimental: {
@@ -9,4 +10,19 @@ const nextConfig: NextConfig = {
910
},
1011
};
1112

12-
export default nextConfig;
13+
// Issue #813: wrap the default config so Sentry's webpack plugin instruments
14+
// client, server, and edge bundles and uploads source maps on build. Runtime
15+
// init lives in sentry.client/server/edge.config.ts; breadcrumbs for wallet
16+
// connection and transaction lifecycle events are captured in
17+
// src/lib/errors/breadcrumbs.ts.
18+
export default withSentryConfig(nextConfig, {
19+
// Options below are only consulted when the corresponding env vars/org are
20+
// configured; builds without a Sentry DSN simply skip source-map upload.
21+
org: process.env.SENTRY_ORG,
22+
project: process.env.SENTRY_PROJECT,
23+
authToken: process.env.SENTRY_AUTH_TOKEN,
24+
silent: !process.env.CI,
25+
widenClientFileUpload: true,
26+
hideSourceMaps: true,
27+
disableLogger: true,
28+
});

frontend/public/sw.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/* SoroTask offline-first service worker (#811).
2+
*
3+
* A hand-rolled Workbox-style service worker that makes the dashboard usable
4+
* under intermittent connectivity:
5+
* - stale-while-revalidate for same-origin app static assets (JS/CSS/images),
6+
* - cache-first for fonts/icons,
7+
* - network-first with cache fallback for read-only task/API queries,
8+
* - navigation requests fall back to the offline page when unreachable.
9+
*
10+
* Kept dependency-free (plain ES2020) so it works in every modern browser and
11+
* needs no build step.
12+
*/
13+
14+
const CACHE_VERSION = 'sorotask-v1';
15+
const STATIC_CACHE = `${CACHE_VERSION}-static`;
16+
const DATA_CACHE = `${CACHE_VERSION}-data`;
17+
18+
const OFFLINE_URL = '/offline';
19+
20+
// Core app-shell requests pre-cached at install time.
21+
const CORE_ASSETS = [
22+
'/',
23+
OFFLINE_URL,
24+
'/manifest.json',
25+
'/icons/icon-192x192.png',
26+
'/icons/icon-512x512.png',
27+
];
28+
29+
const STATIC_EXT = /\.(?:js|css|json|png|jpe?g|gif|svg|webp|woff2?|ttf|otf)$/;
30+
31+
self.addEventListener('install', (event) => {
32+
event.waitUntil(
33+
caches
34+
.open(STATIC_CACHE)
35+
.then((cache) => cache.addAll(CORE_ASSETS))
36+
.then(() => self.skipWaiting())
37+
);
38+
});
39+
40+
self.addEventListener('activate', (event) => {
41+
event.waitUntil(
42+
caches
43+
.keys()
44+
.then((keys) =>
45+
Promise.all(
46+
keys.filter((key) => key !== STATIC_CACHE && key !== DATA_CACHE).map((key) => caches.delete(key))
47+
)
48+
)
49+
.then(() => self.clients.claim())
50+
);
51+
});
52+
53+
// Network requests that mutate state should htever be intercepted; only GET
54+
// traffic is eligible for caching.
55+
function isEligible(request) {
56+
if (request.method !== 'GET') return false;
57+
const url = new URL(request.url);
58+
if (url.origin !== self.location.origin) return false;
59+
return true;
60+
}
61+
62+
// Read-only task/query API calls: network-first, fall back to a cached copy.
63+
function isDataRequest(url) {
64+
return url.pathname.includes('/api/') && !STATIC_EXT.test(url.pathname);
65+
}
66+
67+
self.addEventListener('fetch', (event) => {
68+
const { request } = event;
69+
if (!isEligible(request)) return;
70+
71+
const url = new URL(request.url);
72+
73+
// App shell navigations: network-first, fall back to the offline page.
74+
if (request.mode === 'navigate') {
75+
event.respondWith(
76+
fetch(request)
77+
.then((response) => {
78+
const copy = response.clone();
79+
caches.open(STATIC_CACHE).then((cache) => cache.put(url.href, copy));
80+
return response;
81+
})
82+
.catch(async () => {
83+
const cached = await caches.match(request);
84+
if (cached) return cached;
85+
const offline = await caches.match(OFFLINE_URL);
86+
// FOUC guard: still respond with something even if the offline page
87+
// isn't cached yet.
88+
return offline || new Response('Offline', { status: 200, headers: { 'Content-Type': 'text/html' } });
89+
})
90+
);
91+
return;
92+
}
93+
94+
// Network-first for read-only API/task data.
95+
if (isDataRequest(url) || url.pathname.startsWith('/api/')) {
96+
event.respondWith(
97+
fetch(request)
98+
.then((response) => {
99+
const copy = response.clone();
100+
caches.open(DATA_CACHE).then((cache) => cache.put(url.href, copy));
101+
return response;
102+
})
103+
.catch(async () => {
104+
const cached = await caches.match(request);
105+
return cached || Response.error();
106+
})
107+
);
108+
return;
109+
}
110+
111+
// Static assets and fonts: stale-while-revalidate.
112+
event.respondWith(
113+
caches.match(request).then((cached) => {
114+
const refresh = fetch(request)
115+
.then((response) => {
116+
if (response && response.ok) {
117+
const copy = response.clone();
118+
caches.open(STATIC_CACHE).then((cache) => cache.put(url.href, copy));
119+
}
120+
return response;
121+
})
122+
.catch(() => cached);
123+
return cached || refresh;
124+
})
125+
);
126+
});

0 commit comments

Comments
 (0)