diff --git a/src/components/EmptyState/EmptyState.stories.tsx b/src/components/EmptyState/EmptyState.stories.tsx index b548835..237105e 100644 --- a/src/components/EmptyState/EmptyState.stories.tsx +++ b/src/components/EmptyState/EmptyState.stories.tsx @@ -5,6 +5,7 @@ import { EmptyIllustration, EmptyBridges, EmptyAlerts, + EmptyIncidents, EmptyTransactions, EmptySearch, EmptyConnection, @@ -129,21 +130,21 @@ export const EmptyBridgesWithFilters: Story = { ), }; -export const EmptyAlertsActive: Story = { - name: "EmptyAlerts — active", - render: () => ( - - ), +export const EmptyAlertsDefault: Story = { + name: "EmptyAlerts — no data", + render: () => , }; -export const EmptyAlertsHistory: Story = { - name: "EmptyAlerts — history", - render: () => , +export const EmptyAlertsWithFilters: Story = { + name: "EmptyAlerts — filters active", + render: () => ( + + ), }; -export const EmptyAlertsSuppressed: Story = { - name: "EmptyAlerts — suppressed", - render: () => , +export const EmptyIncidentsDefault: Story = { + name: "EmptyIncidents", + render: () => , }; export const EmptyTransactionsDefault: Story = { @@ -151,9 +152,11 @@ export const EmptyTransactionsDefault: Story = { render: () => , }; -export const EmptyTransactionsBridge: Story = { - name: "EmptyTransactions — bridge specific", - render: () => , +export const EmptyTransactionsWithFilters: Story = { + name: "EmptyTransactions — filters active", + render: () => ( + + ), }; export const EmptySearchNoQuery: Story = { diff --git a/src/components/EmptyState/index.ts b/src/components/EmptyState/index.ts index 192972e..a7d32eb 100644 --- a/src/components/EmptyState/index.ts +++ b/src/components/EmptyState/index.ts @@ -8,6 +8,7 @@ * EmptyIllustration, * EmptyBridges, * EmptyAlerts, + * EmptyIncidents, * EmptyTransactions, * EmptySearch, * EmptyConnection, @@ -24,6 +25,7 @@ export * as EmptyIllustration from "./EmptyIllustration"; export { EmptyBridges, EmptyAlerts, + EmptyIncidents, EmptyTransactions, EmptySearch, EmptyConnection, diff --git a/src/components/EmptyState/variants.test.tsx b/src/components/EmptyState/variants.test.tsx new file mode 100644 index 0000000..eb82212 --- /dev/null +++ b/src/components/EmptyState/variants.test.tsx @@ -0,0 +1,63 @@ +import { Suspense } from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import i18n from "../../i18n/config"; +import { EmptyBridges, EmptyAlerts, EmptyIncidents, EmptyTransactions, EmptyWatchlist } from "./variants"; + +function renderVariant(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +describe("EmptyState variants", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("EmptyBridges shows the no-data message with no clear action by default", async () => { + renderVariant(); + + expect(await screen.findByText("No bridges yet")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument(); + }); + + it("EmptyBridges shows the filtered message and a clear-filters action when hasFilters is set", async () => { + const onClearFilters = vi.fn(); + renderVariant(); + + expect(await screen.findByText("No bridges match your filters")).toBeInTheDocument(); + screen.getByRole("button", { name: "Clear filters" }).click(); + expect(onClearFilters).toHaveBeenCalledTimes(1); + }); + + it("EmptyAlerts distinguishes no data from no matches", async () => { + renderVariant(); + expect(await screen.findByText("No active alerts")).toBeInTheDocument(); + + renderVariant( {}} />); + expect(await screen.findByText("No alerts match your filters")).toBeInTheDocument(); + }); + + it("EmptyIncidents renders a friendly message", async () => { + renderVariant(); + expect(await screen.findByText("No incidents recorded")).toBeInTheDocument(); + }); + + it("EmptyTransactions distinguishes no data from no matches", async () => { + renderVariant(); + expect(await screen.findByText("No transactions found")).toBeInTheDocument(); + + renderVariant( {}} />); + expect(await screen.findByText("No transactions match your filters")).toBeInTheDocument(); + }); + + it("EmptyWatchlist links to the bridges page", async () => { + renderVariant( {}} />); + + expect(await screen.findByText("Your watchlist is empty")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Browse bridges" })).toHaveAttribute("href", "/bridges"); + }); +}); diff --git a/src/components/EmptyState/variants.tsx b/src/components/EmptyState/variants.tsx index 99dda00..29c878e 100644 --- a/src/components/EmptyState/variants.tsx +++ b/src/components/EmptyState/variants.tsx @@ -4,7 +4,7 @@ * Ready-to-use compositions of EmptyState + EmptyIllustration for each * Swipely view. Import the variant that matches the page rather than * constructing props from scratch — this keeps copy and illustrations - * consistent across the app. + * consistent across the app. Copy is routed through react-i18next. * * Usage: * import { EmptyBridges, EmptyAlerts } from "@/components/EmptyState"; @@ -13,6 +13,7 @@ * if (bridges.length === 0) return ; */ +import { useTranslation } from "react-i18next"; import { EmptyState } from "./EmptyState"; import * as EmptyIllustration from "./EmptyIllustration"; @@ -25,17 +26,19 @@ interface EmptyBridgesProps { } export function EmptyBridges({ hasFilters, onClearFilters }: EmptyBridgesProps) { + const { t } = useTranslation(); + if (hasFilters) { return ( } - title="No bridges match your filters" - description="Try adjusting your search or filter criteria to find what you're looking for." + title={t("emptyStates.bridges.filteredTitle")} + description={t("emptyStates.bridges.filteredDescription")} actions={[ - { label: "Clear filters", onClick: onClearFilters, variant: "primary" }, + { label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" }, ]} - ariaLabel="No bridges match the current filters" + ariaLabel={t("emptyStates.bridges.filteredTitle")} /> ); } @@ -44,9 +47,9 @@ export function EmptyBridges({ hasFilters, onClearFilters }: EmptyBridgesProps) } - title="No bridges yet" - description="Swipely hasn't detected any bridges. Data is fetched from the Stellar network automatically — check back shortly." - ariaLabel="No bridges found" + title={t("emptyStates.bridges.title")} + description={t("emptyStates.bridges.description")} + ariaLabel={t("emptyStates.bridges.title")} /> ); } @@ -54,42 +57,52 @@ export function EmptyBridges({ hasFilters, onClearFilters }: EmptyBridgesProps) // ── No alerts ───────────────────────────────────────────────────────────────── interface EmptyAlertsProps { - /** The alerts sub-view the user is on (active, history, suppressed). */ - view?: "active" | "history" | "suppressed"; - onConfigureAlerts?: () => void; + /** Whether any filters or search are active — changes copy and actions. */ + hasFilters?: boolean; + onClearFilters?: () => void; } -export function EmptyAlerts({ view = "active", onConfigureAlerts }: EmptyAlertsProps) { - const copy: Record = { - active: { - title: "No active alerts", - description: - "All monitored bridges are within their configured thresholds. Alerts will appear here when anomalies are detected.", - }, - history: { - title: "No alert history", - description: - "No alerts have been triggered yet. Past alerts will appear here once thresholds are breached.", - }, - suppressed: { - title: "No suppressed alerts", - description: - "You haven't suppressed any alerts. Suppressed alerts are temporarily muted and won't trigger notifications.", - }, - }; +export function EmptyAlerts({ hasFilters, onClearFilters }: EmptyAlertsProps) { + const { t } = useTranslation(); + + if (hasFilters) { + return ( + } + title={t("emptyStates.alerts.filteredTitle")} + description={t("emptyStates.alerts.filteredDescription")} + actions={[ + { label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" }, + ]} + ariaLabel={t("emptyStates.alerts.filteredTitle")} + /> + ); + } return ( } - title={copy[view].title} - description={copy[view].description} - actions={ - view === "active" && onConfigureAlerts - ? [{ label: "Configure alert rules", onClick: onConfigureAlerts, variant: "secondary" }] - : [] - } - ariaLabel={copy[view].title} + title={t("emptyStates.alerts.title")} + description={t("emptyStates.alerts.description")} + ariaLabel={t("emptyStates.alerts.title")} + /> + ); +} + +// ── No incidents ────────────────────────────────────────────────────────────── + +export function EmptyIncidents() { + const { t } = useTranslation(); + + return ( + } + title={t("emptyStates.incidents.title")} + description={t("emptyStates.incidents.description")} + ariaLabel={t("emptyStates.incidents.title")} /> ); } @@ -97,21 +110,36 @@ export function EmptyAlerts({ view = "active", onConfigureAlerts }: EmptyAlertsP // ── No transactions ─────────────────────────────────────────────────────────── interface EmptyTransactionsProps { - bridgeName?: string; + /** Whether any filters are active — changes copy and actions. */ + hasFilters?: boolean; + onClearFilters?: () => void; } -export function EmptyTransactions({ bridgeName }: EmptyTransactionsProps) { +export function EmptyTransactions({ hasFilters, onClearFilters }: EmptyTransactionsProps) { + const { t } = useTranslation(); + + if (hasFilters) { + return ( + } + title={t("emptyStates.transactions.filteredTitle")} + description={t("emptyStates.transactions.filteredDescription")} + actions={[ + { label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" }, + ]} + ariaLabel={t("emptyStates.transactions.filteredTitle")} + /> + ); + } + return ( } - title="No transactions found" - description={ - bridgeName - ? `No transactions have been recorded for ${bridgeName} in the selected time range.` - : "No transactions match the selected filters. Try a different time range." - } - ariaLabel="No transactions found" + title={t("emptyStates.transactions.title")} + description={t("emptyStates.transactions.description")} + ariaLabel={t("emptyStates.transactions.title")} /> ); } @@ -166,18 +194,27 @@ interface EmptyWatchlistProps { } export function EmptyWatchlist({ onBrowseBridges }: EmptyWatchlistProps) { + const { t } = useTranslation(); + return ( } - title="Your watchlist is empty" - description="Star bridges you want to track closely. They'll show up here for quick access." + title={t("emptyStates.watchlist.title")} + description={t("emptyStates.watchlist.description")} actions={ onBrowseBridges - ? [{ label: "Browse bridges", onClick: onBrowseBridges, href: "/bridges", variant: "primary" }] + ? [ + { + label: t("emptyStates.watchlist.browseBridges"), + onClick: onBrowseBridges, + href: "/bridges", + variant: "primary", + }, + ] : [] } - ariaLabel="Watchlist is empty" + ariaLabel={t("emptyStates.watchlist.title")} /> ); } diff --git a/src/components/IncidentHeatmap.tsx b/src/components/IncidentHeatmap.tsx index f7b94a3..ec8a068 100644 --- a/src/components/IncidentHeatmap.tsx +++ b/src/components/IncidentHeatmap.tsx @@ -1,6 +1,7 @@ // src/components/IncidentHeatmap.tsx import React, { useMemo } from "react"; import { useIncidentFeed, type IncidentSeverity, type BridgeIncident } from "../hooks/useIncidentFeed"; +import { EmptyIncidents } from "./EmptyState"; // Helper to bucket incidents by date (YYYY-MM-DD) and asset code function bucketIncidents(incidents: BridgeIncident[]) { @@ -52,8 +53,21 @@ export default function IncidentHeatmap() { Failed to load incidents. )} - {isLoading &&

Loading…

} - {!isLoading && !error && ( + {isLoading && ( +
+ {Array.from({ length: 5 * 6 }).map((_, i) => ( +
+ ))} +
+ )} + {!isLoading && !error && dates.length === 0 && } + {!isLoading && !error && dates.length > 0 && (
{/* Header row */}
diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 7994b60..72b2d18 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -7,6 +7,7 @@ import TransactionRow, { } from "./TransactionRow"; import TransactionDetail from "./TransactionDetail"; import { SkeletonTable } from "./Skeleton"; +import { EmptyTransactions } from "./EmptyState"; import type { BridgeTransaction } from "../types"; type TransactionHistoryProps = { @@ -37,6 +38,13 @@ export default function TransactionHistory({ const transactions = data?.transactions ?? []; const total = data?.total ?? 0; + const hasActiveFilters = + filters.bridge !== "" || + filters.asset !== "" || + filters.status !== "all" || + filters.search !== "" || + filters.dateFrom !== "" || + filters.dateTo !== ""; function handleExport() { const url = exportTransactionsCsv(filters); @@ -128,18 +136,7 @@ export default function TransactionHistory({ {!isLoading && transactions.length === 0 && ( -
-

- No transactions match your filters. -

- -
+ )}
)} @@ -170,18 +167,7 @@ export default function TransactionHistory({ ))} {!isLoading && transactions.length === 0 && ( -
-

- No transactions match your filters. -

- -
+ )}
)} diff --git a/src/components/alerts/CompactAlertList.tsx b/src/components/alerts/CompactAlertList.tsx index c2d465c..524c222 100644 --- a/src/components/alerts/CompactAlertList.tsx +++ b/src/components/alerts/CompactAlertList.tsx @@ -5,6 +5,7 @@ import { type IncidentSeverity, type IncidentStatus, } from "../../hooks/useIncidentFeed"; +import { EmptyAlerts } from "../EmptyState"; type SortField = "severity" | "time" | "status" | "title"; type SortDir = "asc" | "desc"; @@ -319,6 +320,16 @@ export default function CompactAlertList({ }); }, [incidents, dismissedIds, searchQuery, sortField, sortDir]); + const hasActiveFilters = + Boolean(severityFilter) || Boolean(statusFilter) || searchQuery.trim().length > 0 || dismissedIds.size > 0; + + const clearFilters = useCallback(() => { + setSeverityFilter(""); + setStatusFilter(""); + setSearchQuery(""); + setDismissedIds(new Set()); + }, []); + const allSelected = processedIncidents.length > 0 && selectedIds.size === processedIncidents.length; @@ -600,28 +611,7 @@ export default function CompactAlertList({ {/* Empty state */} {!isLoading && !error && processedIncidents.length === 0 && ( -
- -

No alerts found

-

- {dismissedIds.size > 0 - ? `${dismissedIds.size} alert${dismissedIds.size !== 1 ? "s" : ""} dismissed. Adjust filters to see more.` - : "All bridges are operating normally."} -

-
+ )} {/* Alert rows */} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 46b1e2f..a83ff6c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -13,7 +13,8 @@ "export": "تصدير", "refresh": "تحديث", "viewMore": "عرض المزيد", - "backToTop": "العودة للأعلى" + "backToTop": "العودة للأعلى", + "clearFilters": "مسح عوامل التصفية" }, "nav": { "dashboard": "لوحة التحكم", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "جارٍ تحميل الصفحة..." + }, + "emptyStates": { + "bridges": { + "title": "لا توجد جسور بعد", + "description": "لم يكتشف Swipely أي جسور حتى الآن. يتم جلب البيانات تلقائيًا من شبكة Stellar — يُرجى التحقق مرة أخرى قريبًا.", + "filteredTitle": "لا توجد جسور تطابق عوامل التصفية", + "filteredDescription": "حاول تعديل بحثك أو معايير التصفية للعثور على ما تبحث عنه." + }, + "alerts": { + "title": "لا توجد تنبيهات نشطة", + "description": "جميع الجسور المراقبة ضمن الحدود المكوّنة لها. ستظهر التنبيهات هنا عند اكتشاف أي حالات شاذة.", + "filteredTitle": "لا توجد تنبيهات تطابق عوامل التصفية", + "filteredDescription": "حاول تعديل بحثك أو معايير التصفية للعثور على ما تبحث عنه." + }, + "incidents": { + "title": "لم يتم تسجيل أي حوادث", + "description": "لم يتم تسجيل أي حوادث بعد. ستمتلئ الخريطة الحرارية مع وقوع الحوادث عبر الأصول المراقبة." + }, + "transactions": { + "title": "لم يتم العثور على معاملات", + "description": "لم يتم تسجيل أي معاملات ضمن النطاق الزمني المحدد.", + "filteredTitle": "لا توجد معاملات تطابق عوامل التصفية", + "filteredDescription": "حاول تعديل بحثك أو معايير التصفية للعثور على ما تبحث عنه." + }, + "watchlist": { + "title": "قائمة المتابعة فارغة", + "description": "ضع علامة نجمة على الجسور التي تريد متابعتها عن كثب. ستظهر هنا للوصول السريع إليها.", + "browseBridges": "تصفح الجسور" + } } } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ed942ff..740bbf9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -13,7 +13,8 @@ "export": "Exportieren", "refresh": "Aktualisieren", "viewMore": "Mehr anzeigen", - "backToTop": "Nach oben" + "backToTop": "Nach oben", + "clearFilters": "Filter zurücksetzen" }, "nav": { "dashboard": "Dashboard", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "Seite wird geladen..." + }, + "emptyStates": { + "bridges": { + "title": "Noch keine Bridges", + "description": "Swipely hat noch keine Bridges erkannt. Daten werden automatisch vom Stellar-Netzwerk abgerufen — schau bald wieder vorbei.", + "filteredTitle": "Keine Bridges entsprechen deinen Filtern", + "filteredDescription": "Passe deine Suche oder Filterkriterien an, um zu finden, wonach du suchst." + }, + "alerts": { + "title": "Keine aktiven Warnungen", + "description": "Alle überwachten Bridges liegen innerhalb ihrer konfigurierten Schwellenwerte. Warnungen erscheinen hier, sobald Anomalien erkannt werden.", + "filteredTitle": "Keine Warnungen entsprechen deinen Filtern", + "filteredDescription": "Passe deine Suche oder Filterkriterien an, um zu finden, wonach du suchst." + }, + "incidents": { + "title": "Keine Vorfälle erfasst", + "description": "Es wurden noch keine Vorfälle erfasst. Die Heatmap füllt sich, sobald Vorfälle bei überwachten Assets auftreten." + }, + "transactions": { + "title": "Keine Transaktionen gefunden", + "description": "Im ausgewählten Zeitraum wurden keine Transaktionen erfasst.", + "filteredTitle": "Keine Transaktionen entsprechen deinen Filtern", + "filteredDescription": "Passe deine Suche oder Filterkriterien an, um zu finden, wonach du suchst." + }, + "watchlist": { + "title": "Deine Watchlist ist leer", + "description": "Markiere Bridges, die du genau verfolgen möchtest, mit einem Stern. Sie erscheinen hier für schnellen Zugriff.", + "browseBridges": "Bridges durchsuchen" + } } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6ae2806..616e8b8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,8 @@ "export": "Export", "refresh": "Refresh", "viewMore": "View More", - "backToTop": "Back to Top" + "backToTop": "Back to Top", + "clearFilters": "Clear filters" }, "nav": { "dashboard": "Dashboard", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "Loading page..." + }, + "emptyStates": { + "bridges": { + "title": "No bridges yet", + "description": "Swipely hasn't detected any bridges. Data is fetched from the Stellar network automatically — check back shortly.", + "filteredTitle": "No bridges match your filters", + "filteredDescription": "Try adjusting your search or filter criteria to find what you're looking for." + }, + "alerts": { + "title": "No active alerts", + "description": "All monitored bridges are within their configured thresholds. Alerts will appear here when anomalies are detected.", + "filteredTitle": "No alerts match your filters", + "filteredDescription": "Try adjusting your search or filter criteria to find what you're looking for." + }, + "incidents": { + "title": "No incidents recorded", + "description": "No incidents have been logged yet. The heatmap will populate as incidents occur across monitored assets." + }, + "transactions": { + "title": "No transactions found", + "description": "No transactions have been recorded in the selected time range.", + "filteredTitle": "No transactions match your filters", + "filteredDescription": "Try adjusting your search or filter criteria to find what you're looking for." + }, + "watchlist": { + "title": "Your watchlist is empty", + "description": "Star bridges you want to track closely. They'll show up here for quick access.", + "browseBridges": "Browse bridges" + } } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 58dbd13..d7b0a71 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -13,7 +13,8 @@ "export": "Exportar", "refresh": "Actualizar", "viewMore": "Ver más", - "backToTop": "Volver arriba" + "backToTop": "Volver arriba", + "clearFilters": "Borrar filtros" }, "nav": { "dashboard": "Panel", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "Cargando página..." + }, + "emptyStates": { + "bridges": { + "title": "Aún no hay puentes", + "description": "Swipely no ha detectado ningún puente. Los datos se obtienen automáticamente de la red Stellar; vuelve a comprobarlo en breve.", + "filteredTitle": "Ningún puente coincide con tus filtros", + "filteredDescription": "Intenta ajustar tu búsqueda o los criterios de filtro para encontrar lo que buscas." + }, + "alerts": { + "title": "No hay alertas activas", + "description": "Todos los puentes monitorizados están dentro de sus umbrales configurados. Las alertas aparecerán aquí cuando se detecten anomalías.", + "filteredTitle": "Ninguna alerta coincide con tus filtros", + "filteredDescription": "Intenta ajustar tu búsqueda o los criterios de filtro para encontrar lo que buscas." + }, + "incidents": { + "title": "No se han registrado incidentes", + "description": "Aún no se ha registrado ningún incidente. El mapa de calor se completará a medida que ocurran incidentes en los activos monitorizados." + }, + "transactions": { + "title": "No se encontraron transacciones", + "description": "No se han registrado transacciones en el rango de fechas seleccionado.", + "filteredTitle": "Ninguna transacción coincide con tus filtros", + "filteredDescription": "Intenta ajustar tu búsqueda o los criterios de filtro para encontrar lo que buscas." + }, + "watchlist": { + "title": "Tu lista de seguimiento está vacía", + "description": "Marca con una estrella los puentes que quieras seguir de cerca. Aparecerán aquí para un acceso rápido.", + "browseBridges": "Explorar puentes" + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8612d10..1f43533 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -13,7 +13,8 @@ "export": "Exporter", "refresh": "Actualiser", "viewMore": "Voir plus", - "backToTop": "Retour en haut" + "backToTop": "Retour en haut", + "clearFilters": "Effacer les filtres" }, "nav": { "dashboard": "Tableau de bord", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "Chargement de la page..." + }, + "emptyStates": { + "bridges": { + "title": "Aucun pont pour le moment", + "description": "Swipely n'a détecté aucun pont. Les données sont récupérées automatiquement depuis le réseau Stellar — revenez bientôt.", + "filteredTitle": "Aucun pont ne correspond à vos filtres", + "filteredDescription": "Essayez d'ajuster votre recherche ou vos critères de filtre pour trouver ce que vous cherchez." + }, + "alerts": { + "title": "Aucune alerte active", + "description": "Tous les ponts surveillés respectent leurs seuils configurés. Les alertes apparaîtront ici lorsque des anomalies seront détectées.", + "filteredTitle": "Aucune alerte ne correspond à vos filtres", + "filteredDescription": "Essayez d'ajuster votre recherche ou vos critères de filtre pour trouver ce que vous cherchez." + }, + "incidents": { + "title": "Aucun incident enregistré", + "description": "Aucun incident n'a encore été enregistré. La carte de chaleur se remplira au fur et à mesure des incidents sur les actifs surveillés." + }, + "transactions": { + "title": "Aucune transaction trouvée", + "description": "Aucune transaction n'a été enregistrée pour la période sélectionnée.", + "filteredTitle": "Aucune transaction ne correspond à vos filtres", + "filteredDescription": "Essayez d'ajuster votre recherche ou vos critères de filtre pour trouver ce que vous cherchez." + }, + "watchlist": { + "title": "Votre liste de suivi est vide", + "description": "Marquez d'une étoile les ponts que vous souhaitez suivre de près. Ils apparaîtront ici pour un accès rapide.", + "browseBridges": "Parcourir les ponts" + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 01cb717..8fce488 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -13,7 +13,8 @@ "export": "エクスポート", "refresh": "更新", "viewMore": "もっと見る", - "backToTop": "トップに戻る" + "backToTop": "トップに戻る", + "clearFilters": "フィルターをクリア" }, "nav": { "dashboard": "ダッシュボード", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "ページを読み込み中..." + }, + "emptyStates": { + "bridges": { + "title": "ブリッジがまだありません", + "description": "Swipelyはまだブリッジを検出していません。データはStellarネットワークから自動的に取得されます。しばらくしてから再度ご確認ください。", + "filteredTitle": "フィルターに一致するブリッジがありません", + "filteredDescription": "検索条件やフィルター条件を調整して、お探しの内容を見つけてください。" + }, + "alerts": { + "title": "有効なアラートはありません", + "description": "監視中のすべてのブリッジは設定されたしきい値の範囲内です。異常が検出されると、ここにアラートが表示されます。", + "filteredTitle": "フィルターに一致するアラートがありません", + "filteredDescription": "検索条件やフィルター条件を調整して、お探しの内容を見つけてください。" + }, + "incidents": { + "title": "記録されたインシデントはありません", + "description": "まだインシデントは記録されていません。監視対象アセットでインシデントが発生すると、ヒートマップに表示されます。" + }, + "transactions": { + "title": "取引が見つかりません", + "description": "選択した期間内に記録された取引はありません。", + "filteredTitle": "フィルターに一致する取引がありません", + "filteredDescription": "検索条件やフィルター条件を調整して、お探しの内容を見つけてください。" + }, + "watchlist": { + "title": "ウォッチリストは空です", + "description": "注目したいブリッジにスターを付けてください。ここにクイックアクセス用に表示されます。", + "browseBridges": "ブリッジを見る" + } } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 1947ce8..6b09beb 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -13,7 +13,8 @@ "export": "내보내기", "refresh": "새로고침", "viewMore": "더 보기", - "backToTop": "맨 위로" + "backToTop": "맨 위로", + "clearFilters": "필터 지우기" }, "nav": { "dashboard": "대시보드", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "페이지 로딩 중..." + }, + "emptyStates": { + "bridges": { + "title": "아직 브릿지가 없습니다", + "description": "Swipely가 아직 브릿지를 감지하지 못했습니다. 데이터는 Stellar 네트워크에서 자동으로 가져옵니다. 잠시 후 다시 확인해 주세요.", + "filteredTitle": "필터와 일치하는 브릿지가 없습니다", + "filteredDescription": "검색어나 필터 조건을 조정하여 원하는 내용을 찾아보세요." + }, + "alerts": { + "title": "활성 알림이 없습니다", + "description": "모니터링 중인 모든 브릿지가 설정된 임계값 범위 내에 있습니다. 이상이 감지되면 여기에 알림이 표시됩니다.", + "filteredTitle": "필터와 일치하는 알림이 없습니다", + "filteredDescription": "검색어나 필터 조건을 조정하여 원하는 내용을 찾아보세요." + }, + "incidents": { + "title": "기록된 사건이 없습니다", + "description": "아직 기록된 사건이 없습니다. 모니터링 중인 자산에서 사건이 발생하면 히트맵이 채워집니다." + }, + "transactions": { + "title": "거래 내역이 없습니다", + "description": "선택한 기간 동안 기록된 거래가 없습니다.", + "filteredTitle": "필터와 일치하는 거래가 없습니다", + "filteredDescription": "검색어나 필터 조건을 조정하여 원하는 내용을 찾아보세요." + }, + "watchlist": { + "title": "관심 목록이 비어 있습니다", + "description": "자세히 추적하고 싶은 브릿지에 별표를 표시하세요. 빠른 접근을 위해 여기에 표시됩니다.", + "browseBridges": "브릿지 둘러보기" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d11a3fa..ea454d2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -13,7 +13,8 @@ "export": "导出", "refresh": "刷新", "viewMore": "查看更多", - "backToTop": "返回顶部" + "backToTop": "返回顶部", + "clearFilters": "清除筛选" }, "nav": { "dashboard": "仪表板", @@ -196,5 +197,34 @@ }, "app": { "loadingPage": "正在加载页面..." + }, + "emptyStates": { + "bridges": { + "title": "暂无跨链桥", + "description": "Swipely 尚未检测到任何跨链桥。数据会自动从 Stellar 网络获取,请稍后再来查看。", + "filteredTitle": "没有符合筛选条件的跨链桥", + "filteredDescription": "请尝试调整搜索或筛选条件以找到您需要的内容。" + }, + "alerts": { + "title": "暂无活跃告警", + "description": "所有受监控的跨链桥均在配置的阈值范围内。检测到异常时,告警将显示在此处。", + "filteredTitle": "没有符合筛选条件的告警", + "filteredDescription": "请尝试调整搜索或筛选条件以找到您需要的内容。" + }, + "incidents": { + "title": "暂无已记录的事件", + "description": "尚未记录任何事件。随着受监控资产发生事件,热力图将逐步填充。" + }, + "transactions": { + "title": "未找到交易记录", + "description": "所选时间范围内没有记录任何交易。", + "filteredTitle": "没有符合筛选条件的交易", + "filteredDescription": "请尝试调整搜索或筛选条件以找到您需要的内容。" + }, + "watchlist": { + "title": "您的关注列表为空", + "description": "为想要密切关注的跨链桥加星标,它们会显示在此处以便快速访问。", + "browseBridges": "浏览跨链桥" + } } } diff --git a/src/pages/Bridges.tsx b/src/pages/Bridges.tsx index b77d71e..0d24643 100644 --- a/src/pages/Bridges.tsx +++ b/src/pages/Bridges.tsx @@ -11,6 +11,7 @@ import FavoriteTagChip from "../components/favorites/FavoriteTagChip"; import RefreshControls from "../components/RefreshControls"; import PullToRefresh from "../components/PullToRefresh"; import { SkeletonCard, ErrorBoundary } from "../components/Skeleton"; +import { EmptyBridges } from "../components/EmptyState"; import { applyBridgeFilterSort, useBridgeFilterSortStore, @@ -29,6 +30,7 @@ export default function Bridges() { const statusFilter = useBridgeFilterSortStore((s) => s.statusFilter); const sortBy = useBridgeFilterSortStore((s) => s.sortBy); + const setStatusFilter = useBridgeFilterSortStore((s) => s.setStatusFilter); const refreshControls = useRefreshControls({ viewId: "bridges", @@ -164,18 +166,16 @@ export default function Bridges() { ))} ) : ( -
-

- No bridges match your favorites filter. Clear the filter or star bridges from each card. -

-
+ { + setFavoritesFilterMode("all"); + setStatusFilter("all"); + }} + /> ) ) : ( -
-

- No bridge data available. Bridge monitoring will populate this page once configured and running. -

-
+ )} diff --git a/src/pages/Watchlist.test.tsx b/src/pages/Watchlist.test.tsx new file mode 100644 index 0000000..830f390 --- /dev/null +++ b/src/pages/Watchlist.test.tsx @@ -0,0 +1,43 @@ +import { Suspense } from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import i18n from "../i18n/config"; +import { useWatchlistStore } from "../stores/watchlistStore"; +import WatchlistPage from "./Watchlist"; + +function renderWatchlistPage() { + return render( + + + + + , + ); +} + +describe("WatchlistPage", () => { + beforeEach(async () => { + window.localStorage.clear(); + useWatchlistStore.setState(useWatchlistStore.getInitialState(), true); + await i18n.changeLanguage("en"); + }); + + it("shows a friendly empty state when the active watchlist has no assets", async () => { + renderWatchlistPage(); + + expect(await screen.findByText("Your watchlist is empty")).toBeInTheDocument(); + expect( + screen.getByText("Star bridges you want to track closely. They'll show up here for quick access."), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Browse bridges" })).toHaveAttribute("href", "/bridges"); + }); + + it("does not render the empty state once assets are added to the watchlist", async () => { + useWatchlistStore.getState().addAsset("USDC"); + + renderWatchlistPage(); + + expect(await screen.findByText("USDC")).toBeInTheDocument(); + expect(screen.queryByText("Your watchlist is empty")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/Watchlist.tsx b/src/pages/Watchlist.tsx index c49fc25..3093502 100644 --- a/src/pages/Watchlist.tsx +++ b/src/pages/Watchlist.tsx @@ -6,6 +6,8 @@ import { AssetWatchlistButton } from "../components/AssetWatchlistButton"; import { getAssetPrice, getAssetHealth } from "../services/api"; import type { HealthScore } from "../types"; import { Link } from "react-router-dom"; +import { EmptyWatchlist } from "../components/EmptyState"; +import { SkeletonText } from "../components/Skeleton"; interface AssetDetails { price: { @@ -93,18 +95,7 @@ export default function WatchlistPage() { {activeWatchlist.assets.length === 0 ? ( -
-
- - - -
-

No assets in this watchlist yet.

-

Browse the dashboard and click the star icon to add assets here.

- - Go to Dashboard → - -
+ {}} /> ) : (
@@ -139,18 +130,26 @@ export default function WatchlistPage() {
- - {isLoading ? "..." : data?.price?.vwap ? `$${data.price.vwap.toFixed(4)}` : "—"} - + {isLoading ? ( + + ) : ( + + {data?.price?.vwap ? `$${data.price.vwap.toFixed(4)}` : "—"} + + )} - = 80 ? "bg-green-400/10 text-green-400 border-green-400/20" : - typeof healthScore === "number" && healthScore >= 50 ? "bg-yellow-400/10 text-yellow-400 border-yellow-400/20" : - "bg-red-400/10 text-red-400 border-red-400/20" - }`}> - {isLoading ? "..." : healthScore ?? "—"} - + {isLoading ? ( + + ) : ( + = 80 ? "bg-green-400/10 text-green-400 border-green-400/20" : + typeof healthScore === "number" && healthScore >= 50 ? "bg-yellow-400/10 text-yellow-400 border-yellow-400/20" : + "bg-red-400/10 text-red-400 border-red-400/20" + }`}> + {healthScore ?? "—"} + + )}