fix: tier 4 — offer role trust, proof replay, crew alerts, error/nav i18n
- H13 offer threads: the seller is ALWAYS the listing author (derived from the event pubkey), never a self-declared role tag — a non-owner can no longer appear as the seller with a payment pointer. - H12 trade reputation: a preimage/deposit proof counts at most once, so a real payment can't be reused across attestations to inflate payment-verified. - Crew alerts: listed crews are filtered against the user's own membership confirmation labels (one batched query), so a confirmed member no longer gets a phantom 'confirm to join' badge/notification when the crew is edited. - Error boundaries (app + route) and DesktopNavManager are translated; the class boundary renders a translated functional ErrorFallback. New batch4 namespaces errorsUi.* / desktopNav.*.
This commit is contained in:
parent
5596c519c6
commit
caa5f8c56e
10 changed files with 229 additions and 93 deletions
|
|
@ -39,10 +39,9 @@ export function DesktopNavManager() {
|
|||
<Monitor className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-bold text-white">Desktop navigation</h3>
|
||||
<h3 className="text-base font-bold text-white">{t('desktopNav.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which tabs appear in the top bar and their order. Hidden tabs
|
||||
still live in the “More” menu.
|
||||
{t('desktopNav.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,18 +51,18 @@ export function DesktopNavManager() {
|
|||
<fixedMapTab.icon className="h-4 w-4 text-red-400" />
|
||||
<span className="flex-1 text-sm font-bold text-white">{label(fixedMapTab)}</span>
|
||||
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||
<Lock className="h-3 w-3" /> Fixed
|
||||
<Lock className="h-3 w-3" /> {t('desktopNav.fixed')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Shown (primary) tabs — reorderable */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
Shown in top bar ({primaryIds.length})
|
||||
{t('desktopNav.shownInTopBar', { count: primaryIds.length })}
|
||||
</p>
|
||||
{primaryIds.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-white/10 p-3 text-xs text-gray-500">
|
||||
No extra tabs shown — everything is in the “More” menu. Add some below.
|
||||
{t('desktopNav.noExtraTabs')}
|
||||
</p>
|
||||
)}
|
||||
{primaryIds.map((id, i) => {
|
||||
|
|
@ -80,7 +79,7 @@ export function DesktopNavManager() {
|
|||
type="button"
|
||||
onClick={() => move(id, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={`Move ${label(tab)} up`}
|
||||
aria-label={t('desktopNav.moveUp', { name: label(tab) })}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
|
|
@ -89,7 +88,7 @@ export function DesktopNavManager() {
|
|||
type="button"
|
||||
onClick={() => move(id, 1)}
|
||||
disabled={i === primaryIds.length - 1}
|
||||
aria-label={`Move ${label(tab)} down`}
|
||||
aria-label={t('desktopNav.moveDown', { name: label(tab) })}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
|
@ -97,8 +96,8 @@ export function DesktopNavManager() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => hide(id)}
|
||||
aria-label={`Hide ${label(tab)}`}
|
||||
title="Move to More menu"
|
||||
aria-label={t('desktopNav.hide', { name: label(tab) })}
|
||||
title={t('desktopNav.moveToMore')}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-amber-400"
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
|
|
@ -112,7 +111,7 @@ export function DesktopNavManager() {
|
|||
{hiddenIds.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
In “More” menu ({hiddenIds.length})
|
||||
{t('desktopNav.inMoreMenu', { count: hiddenIds.length })}
|
||||
</p>
|
||||
{hiddenIds.map((id) => {
|
||||
const tab = byId(id);
|
||||
|
|
@ -131,11 +130,11 @@ export function DesktopNavManager() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => show(id)}
|
||||
aria-label={`Show ${label(tab)} in top bar`}
|
||||
title="Show in top bar"
|
||||
aria-label={t('desktopNav.showInTopBar', { name: label(tab) })}
|
||||
title={t('desktopNav.showInTopBarTitle')}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-xs font-bold text-gray-300 transition-colors hover:border-red-500/40 hover:text-red-400"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> Show
|
||||
<Eye className="h-3.5 w-3.5" /> {t('desktopNav.show')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -149,7 +148,7 @@ export function DesktopNavManager() {
|
|||
onClick={reset}
|
||||
className="gap-2 border-white/15 text-gray-300 hover:text-white"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Reset to default
|
||||
<RotateCcw className="h-4 w-4" /> {t('desktopNav.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
|
|
@ -57,37 +58,11 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-4xl">
|
||||
🛹
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-black uppercase text-[#ff0040]">
|
||||
Slammed
|
||||
</h2>
|
||||
<p className="text-gray-400">
|
||||
Something tripped up this screen. Your account and data are safe —
|
||||
let's get you rolling again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-3 rounded-full font-bold border border-white/20 text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Reload app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorFallback
|
||||
variant="app"
|
||||
onReset={this.handleReset}
|
||||
onReload={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/components/ErrorFallback.tsx
Normal file
79
src/components/ErrorFallback.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Translated error-boundary UI. A FUNCTION component so it can use `t()` — the
|
||||
* class ErrorBoundary renders it, which keeps the class (React error boundaries
|
||||
* must be classes) while making the copy language-aware.
|
||||
*/
|
||||
export function ErrorFallback({
|
||||
variant,
|
||||
onReset,
|
||||
onReload,
|
||||
onBack,
|
||||
}: {
|
||||
variant: 'app' | 'route';
|
||||
onReset?: () => void;
|
||||
onReload: () => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isApp = variant === 'app';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isApp
|
||||
? 'min-h-screen bg-black text-white flex items-center justify-center p-4'
|
||||
: 'min-h-[60vh] flex items-center justify-center p-6'
|
||||
}
|
||||
>
|
||||
<div className={isApp ? 'max-w-md w-full space-y-6 text-center' : 'max-w-sm w-full text-center space-y-5'}>
|
||||
<div
|
||||
className={
|
||||
isApp
|
||||
? 'mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-4xl'
|
||||
: 'mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-3xl'
|
||||
}
|
||||
>
|
||||
🛹
|
||||
</div>
|
||||
<div className={isApp ? 'space-y-2' : 'space-y-1.5'}>
|
||||
<h2 className={isApp ? 'text-2xl font-black uppercase text-[#ff0040]' : 'text-xl font-black uppercase text-[#ff0040]'}>
|
||||
{isApp ? t('errorsUi.slammedTitle') : t('errorsUi.pageSlammedTitle')}
|
||||
</h2>
|
||||
<p className={isApp ? 'text-gray-400' : 'text-sm text-gray-400'}>
|
||||
{isApp ? t('errorsUi.slammedBody') : t('errorsUi.pageSlammedBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
|
||||
>
|
||||
{t('errorsUi.tryAgain')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onReload}
|
||||
className={
|
||||
isApp
|
||||
? 'flex-1 px-4 py-3 rounded-full font-bold border border-white/20 text-white hover:bg-white/5 transition-colors'
|
||||
: 'flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors'
|
||||
}
|
||||
>
|
||||
{isApp ? t('errorsUi.reloadApp') : t('errorsUi.reload')}
|
||||
</button>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-white/5 text-gray-300 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{t('errorsUi.goBack')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||
|
||||
/**
|
||||
* Per-route error boundary.
|
||||
|
|
@ -29,36 +30,11 @@ export function RouteErrorBoundary({ children }: { children: React.ReactNode })
|
|||
<ErrorBoundary
|
||||
key={location.pathname}
|
||||
fallback={
|
||||
<div className="min-h-[60vh] flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full text-center space-y-5">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-3xl">
|
||||
🛹
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-xl font-black uppercase text-[#ff0040]">
|
||||
This page slammed
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Something on this screen tripped up. Your account and data are safe.
|
||||
Usually a quick reload sorts it out.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={hardReload}
|
||||
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-white/5 text-gray-300 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorFallback
|
||||
variant="route"
|
||||
onReload={hardReload}
|
||||
onBack={() => window.history.back()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
|
||||
import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests';
|
||||
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { SKATE_CREW_KIND, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { SKATE_CREW_KIND, MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
|
||||
export interface CrewNotification {
|
||||
/** Stable id for the notification (invite id or crew coord). */
|
||||
|
|
@ -29,6 +31,7 @@ export interface CrewNotification {
|
|||
*/
|
||||
export function useCrewNotifications() {
|
||||
const { user } = useCurrentUser();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
|
||||
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
|
||||
const { data: collectiveRequestsRaw, isLoading: collectiveLoading } =
|
||||
|
|
@ -46,14 +49,40 @@ export function useCrewNotifications() {
|
|||
}, [invites, dismissed]);
|
||||
|
||||
// Crews that list me as a member (candidates for "pending confirmation").
|
||||
// One query resolves which crews I've ALREADY confirmed, so a confirmed member
|
||||
// never gets a phantom "confirm to join" alert when the crew is edited. While
|
||||
// it loads we return none (better a beat's delay than a false notification).
|
||||
const { data: confirmedCoords, isLoading: membershipLoading } = useQuery<Set<string>>({
|
||||
queryKey: ['my-membership-labels', user?.pubkey, getQueryKeySuffix()],
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!user?.pubkey) return new Set<string>();
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [1985], authors: [user.pubkey], '#l': [MEMBERSHIP_LABEL_VALUE], limit: 500 }],
|
||||
{ signal },
|
||||
);
|
||||
const coords = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === MEMBERSHIP_LABEL_NAMESPACE);
|
||||
const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === MEMBERSHIP_LABEL_VALUE);
|
||||
if (!hasNamespace || !hasLabel) continue;
|
||||
for (const [n, v] of ev.tags) if (n === 'a' && v) coords.add(v);
|
||||
}
|
||||
return coords;
|
||||
},
|
||||
});
|
||||
|
||||
const listedCrews = useMemo(() => {
|
||||
if (!user || !crews) return [];
|
||||
if (!user || !crews || !confirmedCoords) return [];
|
||||
return crews.filter(
|
||||
(c) =>
|
||||
c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() &&
|
||||
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()),
|
||||
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()) &&
|
||||
!confirmedCoords.has(`${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}`),
|
||||
);
|
||||
}, [user, crews]);
|
||||
}, [user, crews, confirmedCoords]);
|
||||
|
||||
// Resolve each incoming join-request to its crew (for approval + display).
|
||||
const requestsWithCrew = useMemo(() => {
|
||||
|
|
@ -115,7 +144,7 @@ export function useCrewNotifications() {
|
|||
listedCrews,
|
||||
joinRequests: requestsWithEndorsements,
|
||||
collectiveRequests,
|
||||
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading,
|
||||
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading || membershipLoading,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,23 +85,22 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
|
|||
const coord = parseListingCoord(a);
|
||||
if (!d || !a || !coord) return null;
|
||||
|
||||
// Explicit `role` tag wins; fall back to the legacy `d` marker.
|
||||
const roleTag = tag(event, 'role');
|
||||
const role: OfferRole =
|
||||
roleTag === 'response' || roleTag === 'offer'
|
||||
? roleTag
|
||||
: d.includes(':offer-response:')
|
||||
? 'response'
|
||||
: 'offer';
|
||||
|
||||
const counterparty = tag(event, 'p');
|
||||
|
||||
// SECURITY: the seller is ALWAYS the listing author. Never trust a
|
||||
// self-declared `role` tag (or a `p` tag) to decide who the seller is — a
|
||||
// non-owner could otherwise post `role=response` with a `payment` pointer and
|
||||
// appear as the seller, tricking the buyer into paying them.
|
||||
const seller = coord.pubkey;
|
||||
const isSeller = event.pubkey.toLowerCase() === coord.pubkey.toLowerCase();
|
||||
const role: OfferRole = isSeller ? 'response' : 'offer';
|
||||
|
||||
const amountTag = event.tags.find(([n]) => n === 'amount');
|
||||
const status = (tag(event, 'status') as OfferStatus) ?? 'open';
|
||||
// `["payment", "<value>", "<kind>"]` — the seller's pointer, attached on accept.
|
||||
const paymentTag = event.tags.find(([n]) => n === 'payment');
|
||||
|
||||
const buyer = role === 'offer' ? event.pubkey : (counterparty ?? '');
|
||||
const seller = role === 'offer' ? (counterparty ?? coord.pubkey) : event.pubkey;
|
||||
const buyer = isSeller ? (counterparty ?? '') : event.pubkey;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
|
|
|
|||
|
|
@ -281,12 +281,19 @@ export function useTradeReputation(pubkey: string | undefined) {
|
|||
// • a Lightning preimage (`sha256(preimage) === payment_hash`), and
|
||||
// • a NIP-57 zap receipt (signature + recipient + amount, fetched below).
|
||||
const zapProofs = new Map<string, string>(); // receiptId → expected seller
|
||||
const usedPreimages = new Set<string>(); // one payment counts once
|
||||
let paymentVerified = 0;
|
||||
for (const ev of unique) {
|
||||
const proof = ev.tags.find(([t]) => t === 'proof');
|
||||
if (!proof) continue;
|
||||
if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) {
|
||||
if (verifyReceiptProof(proof[2], proof[3])) paymentVerified += 1;
|
||||
// SECURITY: a real preimage reused across many attestations must not
|
||||
// inflate the "payment-verified" tier — count each preimage once.
|
||||
const preimage = proof[2];
|
||||
if (!usedPreimages.has(preimage) && verifyReceiptProof(preimage, proof[3])) {
|
||||
usedPreimages.add(preimage);
|
||||
paymentVerified += 1;
|
||||
}
|
||||
} else if (proof[1] === 'zap' && proof[2]) {
|
||||
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||||
if (coord) zapProofs.set(proof[2], coord.pubkey);
|
||||
|
|
|
|||
39
src/i18n/batch4/desktopNav.ts
Normal file
39
src/i18n/batch4/desktopNav.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Batch 4 — desktop nav manager (`desktopNav.*`). Keys must exist in BOTH languages.
|
||||
*/
|
||||
const desktopNav = {
|
||||
en: {
|
||||
title: 'Desktop navigation',
|
||||
desc: 'Choose which tabs appear in the top bar and their order. Hidden tabs still live in the “More” menu.',
|
||||
fixed: 'Fixed',
|
||||
shownInTopBar: 'Shown in top bar ({{count}})',
|
||||
noExtraTabs: 'No extra tabs shown — everything is in the “More” menu. Add some below.',
|
||||
inMoreMenu: 'In “More” menu ({{count}})',
|
||||
moveUp: 'Move {{name}} up',
|
||||
moveDown: 'Move {{name}} down',
|
||||
hide: 'Hide {{name}}',
|
||||
moveToMore: 'Move to More menu',
|
||||
show: 'Show',
|
||||
showInTopBar: 'Show {{name}} in top bar',
|
||||
showInTopBarTitle: 'Show in top bar',
|
||||
reset: 'Reset to default',
|
||||
},
|
||||
es: {
|
||||
title: 'Navegación de escritorio',
|
||||
desc: 'Elige qué pestañas aparecen en la barra superior y su orden. Las ocultas siguen en el menú «Más».',
|
||||
fixed: 'Fija',
|
||||
shownInTopBar: 'Mostradas en la barra ({{count}})',
|
||||
noExtraTabs: 'No hay pestañas extra; todo está en el menú «Más». Añade alguna abajo.',
|
||||
inMoreMenu: 'En el menú «Más» ({{count}})',
|
||||
moveUp: 'Subir {{name}}',
|
||||
moveDown: 'Bajar {{name}}',
|
||||
hide: 'Ocultar {{name}}',
|
||||
moveToMore: 'Mover al menú «Más»',
|
||||
show: 'Mostrar',
|
||||
showInTopBar: 'Mostrar {{name}} en la barra',
|
||||
showInTopBarTitle: 'Mostrar en la barra',
|
||||
reset: 'Restablecer',
|
||||
},
|
||||
};
|
||||
|
||||
export default desktopNav;
|
||||
27
src/i18n/batch4/errorsUi.ts
Normal file
27
src/i18n/batch4/errorsUi.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Batch 4 — error-boundary copy (`errorsUi.*`). Keys must exist in BOTH languages.
|
||||
*/
|
||||
const errorsUi = {
|
||||
en: {
|
||||
slammedTitle: 'Slammed',
|
||||
slammedBody: "Something tripped up this screen. Your account and data are safe — let's get you rolling again.",
|
||||
tryAgain: 'Try again',
|
||||
reloadApp: 'Reload app',
|
||||
pageSlammedTitle: 'This page slammed',
|
||||
pageSlammedBody: 'Something on this screen tripped up. Your account and data are safe. Usually a quick reload sorts it out.',
|
||||
reload: 'Reload',
|
||||
goBack: 'Go back',
|
||||
},
|
||||
es: {
|
||||
slammedTitle: '¡Cata!',
|
||||
slammedBody: 'Algo ha fallado en esta pantalla. Tu cuenta y tus datos están a salvo; volvamos a rodar.',
|
||||
tryAgain: 'Reintentar',
|
||||
reloadApp: 'Recargar app',
|
||||
pageSlammedTitle: 'Esta página se ha caído',
|
||||
pageSlammedBody: 'Algo ha fallado en esta pantalla. Tu cuenta y tus datos están a salvo. Normalmente, recargar lo soluciona.',
|
||||
reload: 'Recargar',
|
||||
goBack: 'Volver',
|
||||
},
|
||||
};
|
||||
|
||||
export default errorsUi;
|
||||
|
|
@ -2,6 +2,8 @@ import labOptions from './labOptions';
|
|||
import sasquatchChat from './sasquatchChat';
|
||||
import feesUi from './feesUi';
|
||||
import navSheet from './navSheet';
|
||||
import errorsUi from './errorsUi';
|
||||
import desktopNav from './desktopNav';
|
||||
|
||||
/** Namespaces added by the batch-4 translation pass, spread into `en`. */
|
||||
export const batch4En = {
|
||||
|
|
@ -9,6 +11,8 @@ export const batch4En = {
|
|||
sasquatchChat: sasquatchChat.en,
|
||||
feesUi: feesUi.en,
|
||||
navSheet: navSheet.en,
|
||||
errorsUi: errorsUi.en,
|
||||
desktopNav: desktopNav.en,
|
||||
};
|
||||
|
||||
/** Namespaces added by the batch-4 translation pass, spread into `es`. */
|
||||
|
|
@ -17,4 +21,6 @@ export const batch4Es = {
|
|||
sasquatchChat: sasquatchChat.es,
|
||||
feesUi: feesUi.es,
|
||||
navSheet: navSheet.es,
|
||||
errorsUi: errorsUi.es,
|
||||
desktopNav: desktopNav.es,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue