Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions web/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@
"confirmDelete": "Delete the connection \"{{name}}\"? Its credentials are erased immediately.",
"confirmPurge": "Also delete all data imported from it? (Cancel keeps the history.)",
"emptyTitle": "Connect your first broker",
"emptyBody": "Links are read-only: they import holdings, trades, dividends and cash movements, and can never place orders or move money. Follow the guide below — it takes about five minutes.",
"emptyBody": "Links are read-only: they import holdings, trades, dividends and cash movements, and can never place orders or move money. Pick your broker below: a step-by-step guide opens with the form — it takes about five minutes.",
"checking": "Checking connection…",
"setupGuide": "Where do I find these? Step-by-step guide",
"fixHint": "How to fix it",
Expand Down Expand Up @@ -659,6 +659,16 @@
"profile": {
"label": "Profile",
"hint": "a or b — both hold the same ETF to show consolidation."
},
"Binance": {
"apiKey": {
"label": "API Key",
"hint": "From Binance → API Management, next to the key."
},
"apiSecret": {
"label": "Secret Key",
"hint": "Shown once, right after the key is created."
}
}
},
"env": {
Expand Down Expand Up @@ -722,7 +732,7 @@
},
"6": {
"title": "Paste them here",
"body": "Click <strong>+ Trading 212</strong>, paste the key and secret, keep <strong>Live</strong> and save. The first sync starts right away; if something is wrong the card tells you what to fix."
"body": "Paste the key and secret in the form below, keep <strong>Live</strong> and save. The first sync starts right away; if something is wrong the card tells you what to fix."
}
},
"onTitle": "Switch ON",
Expand Down Expand Up @@ -769,7 +779,7 @@
},
"7": {
"title": "Paste them here",
"body": "Click <strong>+ Interactive Brokers</strong>, paste the token and the query ID and enter the token's expiry date. The first sync backfills several years and can take a few minutes."
"body": "Paste the token and the query ID in the form below and enter the token's expiry date. The first sync backfills several years and can take a few minutes."
}
},
"onTitle": "Sections to include",
Expand Down Expand Up @@ -812,7 +822,7 @@
},
"6": {
"title": "Paste them here",
"body": "Click <strong>+ Binance</strong>, paste the API Key and the Secret Key and save. The first sync reads your spot and Earn balances and the last 180 days of Earn rewards."
"body": "Paste the API Key and the Secret Key in the form below and save. The first sync reads your spot and Earn balances and the last 180 days of Earn rewards."
}
},
"onTitle": "Keep ON",
Expand Down
44 changes: 27 additions & 17 deletions web/public/i18n/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@
"confirmDelete": "Eliminar a ligação \"{{name}}\"? As credenciais são apagadas de imediato.",
"confirmPurge": "Apagar também todos os dados importados? (Cancelar mantém o histórico.)",
"emptyTitle": "Liga a tua primeira corretora",
"emptyBody": "As ligações são só de leitura: importam posições, negócios, dividendos e movimentos de caixa, e nunca podem dar ordens nem mover dinheiro. Segue o guia abaixo — demora cerca de cinco minutos.",
"emptyBody": "As ligações são só de leitura: importam posições, negócios, dividendos e movimentos de caixa, e nunca podem dar ordens nem mover dinheiro. Escolhe a tua corretora abaixo: o guia passo a passo abre com o formulário — demora cerca de cinco minutos.",
"checking": "A verificar a ligação…",
"setupGuide": "Onde encontro isto? Guia passo a passo",
"fixHint": "Como resolver",
Expand Down Expand Up @@ -659,6 +659,16 @@
"profile": {
"label": "Perfil",
"hint": "a ou b — ambos têm o mesmo ETF para mostrar a consolidação."
},
"Binance": {
"apiKey": {
"label": "API Key",
"hint": "Na Binance → Gestão de API, junto à chave."
},
"apiSecret": {
"label": "Secret Key",
"hint": "Mostrada uma só vez, logo após criares a chave."
}
}
},
"env": {
Expand Down Expand Up @@ -722,7 +732,7 @@
},
"6": {
"title": "Cola-os aqui",
"body": "Clica em <strong>+ Trading 212</strong>, cola a chave e o segredo, mantém <strong>Real</strong> e guarda. A primeira sincronização começa logo; se algo estiver mal, o cartão diz-te o que corrigir."
"body": "Cola a chave e o segredo no formulário abaixo, mantém <strong>Real</strong> e guarda. A primeira sincronização começa logo; se algo estiver mal, o cartão diz-te o que corrigir."
}
},
"onTitle": "ATIVAR",
Expand Down Expand Up @@ -769,7 +779,7 @@
},
"7": {
"title": "Cola-os aqui",
"body": "Clica em <strong>+ Interactive Brokers</strong>, cola o token e o ID da query e indica a data de expiração do token. A primeira sincronização importa vários anos e pode demorar alguns minutos."
"body": "Cola o token e o ID da query no formulário abaixo e indica a data de expiração do token. A primeira sincronização importa vários anos e pode demorar alguns minutos."
}
},
"onTitle": "Secções a incluir",
Expand All @@ -791,33 +801,33 @@
"intro": "A Binance liga-se com uma chave API que só pode ler. Antes de cada sincronização a app pergunta à Binance o que a chave pode fazer e recusa-a se puder negociar, transferir ou levantar.",
"steps": {
"1": {
"title": "Confirme que pode criar chaves API",
"body": "A Binance só cria chaves API numa conta <strong>verificada</strong> e com <strong>autenticação de dois fatores</strong> ativa. Use o site (binance.com) — para isto é mais simples do que a app."
"title": "Confirma que podes criar chaves API",
"body": "A Binance só cria chaves API numa conta <strong>verificada</strong> e com <strong>autenticação de dois fatores</strong> ativa. Usa o site (binance.com) — para isto é mais simples do que a app."
},
"2": {
"title": "Crie a chave",
"body": "Vá a <strong>Perfil → Conta → Gestão de API → Criar API</strong>, escolha <strong>Gerada pelo sistema</strong>, dê-lhe um nome, p. ex. <code>personal-finance</code>, e confirme com 2FA."
"title": "Cria a chave",
"body": "Vai a <strong>Perfil → Conta → Gestão de API → Criar API</strong>, escolhe <strong>Gerada pelo sistema</strong>, dá-lhe um nome, p. ex. <code>personal-finance</code>, e confirma com 2FA."
},
"3": {
"title": "Deixe só a leitura ligada",
"body": "Clique em <strong>Editar restrições</strong>. Mantenha <strong>Enable Reading</strong> ativo e tudo o resto desligado. Uma chave com qualquer outra permissão é recusada pela app."
"title": "Deixa só a leitura ligada",
"body": "Clica em <strong>Editar restrições</strong>. Mantém <strong>Enable Reading</strong> ativo e tudo o resto desligado. Uma chave com qualquer outra permissão é recusada pela app."
},
"4": {
"title": "Restrinja-a ao seu IP",
"body": "Escolha <strong>Restringir o acesso apenas a IPs de confiança</strong> e indique o IP público do servidor (no servidor: <code>curl -4 ifconfig.me</code>). Guarde — a Binance volta a pedir 2FA."
"title": "Restringe-a ao teu IP",
"body": "Escolhe <strong>Restringir o acesso apenas a IPs de confiança</strong> e indica o IP público do servidor (no servidor: <code>curl -4 ifconfig.me</code>). Guarda — a Binance volta a pedir 2FA."
},
"5": {
"title": "Copie a API Key e a Secret Key",
"body": "A <strong>Secret Key</strong> só aparece <strong>uma vez</strong>, logo a seguir a criar a chave. Copie as duas agora; se perder o segredo, apague a chave e crie outra."
"title": "Copia a API Key e a Secret Key",
"body": "A <strong>Secret Key</strong> só aparece <strong>uma vez</strong>, logo a seguir a criares a chave. Copia as duas agora; se perderes o segredo, apaga a chave e cria outra."
},
"6": {
"title": "Cole-as aqui",
"body": "Clique em <strong>+ Binance</strong>, cole a API Key e a Secret Key e guarde. A primeira sincronização lê os saldos spot e Earn e as recompensas Earn dos últimos 180 dias."
"title": "Cola-as aqui",
"body": "Cola a API Key e a Secret Key no formulário abaixo e guarda. A primeira sincronização lê os saldos spot e Earn e as recompensas Earn dos últimos 180 dias."
}
},
"onTitle": "Manter LIGADO",
"on": {
"1": "<strong>Enable Reading</strong> — saldos, posições e recompensas Simple Earn, as suas execuções (para o preço médio)"
"1": "<strong>Enable Reading</strong> — saldos, posições e recompensas Simple Earn, as tuas execuções (para o preço médio)"
},
"offTitle": "Deixar DESLIGADO",
"off": {
Expand All @@ -827,7 +837,7 @@
"4": "<strong>Permits Universal Transfer</strong>",
"5": "<strong>Futures, European Options</strong> e qualquer outra permissão de negociação"
},
"revoke": "Revogue quando quiser: Binance → Gestão de API → Eliminar ao lado da chave. Deixa de funcionar de imediato.",
"revoke": "Revoga quando quiseres: Binance → Gestão de API → Eliminar ao lado da chave. Deixa de funcionar de imediato.",
"csv": "Não há importação de ficheiros da Binance. Moedas noutras exchanges ou numa carteira podem ser adicionadas à mão em Portfólio → Adicionar cripto; a mesma moeda na Binance e à mão aparece como uma só posição."
}
},
Expand Down
119 changes: 41 additions & 78 deletions web/src/app/features/connections/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,11 @@ type CredentialField = ProviderInfo['fields'][number];
} @else {
<p>{{ c.lastError }}</p>
}
@if (guideFor(c.kind); as g) {
@if (guideFor(c.kind)) {
<button
type="button"
class="mt-1 font-medium underline underline-offset-2"
(click)="showGuide(g)"
(click)="fixCredentials(c)"
>
{{ 'connections.fixHint' | translate }}
</button>
Expand Down Expand Up @@ -330,57 +330,6 @@ type CredentialField = ProviderInfo['fields'][number];
}
</div>

@if (guideBrokers().length) {
<details
id="connection-guide"
class="card group mt-6"
[open]="guideOpen()"
(toggle)="guideToggled.set($any($event.target).open)"
>
<summary
class="-m-5 flex cursor-pointer list-none items-center gap-3 rounded-xl p-5 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span
class="bg-primary/10 text-primary dark:bg-primary/20 flex size-9 shrink-0 items-center justify-center rounded-lg"
aria-hidden="true"
>
<ng-icon name="lucideBookOpen" class="text-lg" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold">{{ 'connections.guide.title' | translate }}</span>
<span class="block text-sm text-muted-foreground">{{
'connections.guide.subtitle' | translate
}}</span>
</span>
<ng-icon
name="lucideChevronDown"
class="text-muted-foreground shrink-0 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div class="mt-8">
@if (guideBrokers().length > 1) {
<div class="segmented mb-4" role="tablist">
@for (b of guideBrokers(); track b) {
<button
type="button"
role="tab"
[class.active]="guideKind() === b"
[attr.aria-selected]="guideKind() === b"
(click)="pickedGuide.set(b)"
>
<app-broker-logo [broker]="b" [size]="16" class="mr-1.5" />{{
'source.' + b | translate
}}
</button>
}
</div>
}
<app-broker-guide [kind]="guideKind()" />
</div>
</details>
}

<app-modal
[open]="!!provider()"
[title]="provider()?.name ?? ''"
Expand All @@ -395,7 +344,13 @@ type CredentialField = ProviderInfo['fields'][number];
novalidate
>
@if (guideFor(p.kind); as g) {
<details class="rounded-xl border border-border p-3">
<!-- The one place the guide lives: open on a first connection of this broker or when fixing an error. -->
<details
id="connection-guide"
class="rounded-xl border border-border p-3"
[open]="guideExpanded()"
(toggle)="guideExpanded.set($any($event.target).open)"
>
<summary class="flex cursor-pointer items-center gap-2 text-sm font-medium">
<ng-icon name="lucideBookOpen" class="text-primary" aria-hidden="true" />
{{ 'connections.setupGuide' | translate }}
Expand Down Expand Up @@ -624,19 +579,8 @@ export class ConnectionsComponent implements OnDestroy {
protected readonly environments = ['live', 'demo'];
protected readonly friendly = friendlyError;

/** Brokers with a step-by-step guide, in the order the server lists them. */
protected readonly guideBrokers = computed(() =>
(this.providers.value() ?? []).map((p) => p.kind).filter((k) => this.isGuided(k)),
);
protected readonly pickedGuide = signal<GuideBroker | null>(null);
protected readonly guideKind = computed(
() => this.pickedGuide() ?? this.guideBrokers()[0] ?? 'Trading212',
);
/** The guide is open until the first broker is connected; afterwards the user decides. */
protected readonly guideToggled = signal<boolean | null>(null);
protected readonly guideOpen = computed(
() => this.guideToggled() ?? (this.connections.value()?.length ?? 0) === 0,
);
/** The dialog's step-by-step guide: expanded for a broker's first connection and when fixing an error. */
protected readonly guideExpanded = signal(false);

ngOnDestroy() {
clearInterval(this.poll);
Expand Down Expand Up @@ -665,22 +609,22 @@ export class ConnectionsComponent implements OnDestroy {
return this.isGuided(kind) ? kind : null;
}

/** Opens the page guide on a broker's tab and scrolls to it (from a connection's error). */
protected showGuide(kind: GuideBroker) {
this.pickedGuide.set(kind);
this.guideToggled.set(true);
setTimeout(() =>
document.getElementById('connection-guide')?.scrollIntoView({ behavior: 'smooth' }),
);
}

/** Field labels and hints are translated here; the server's English text is the fallback. */
/** A broker's own wording first (two brokers both call a field "apiKey"), then the shared one. */
protected fieldLabel(f: CredentialField) {
return this.translated(`connections.fields.${f.key}.label`) ?? f.label;
return this.fieldText(f, 'label') ?? f.label;
}

protected fieldHint(f: CredentialField) {
return this.translated(`connections.fields.${f.key}.hint`) ?? f.hint;
return this.fieldText(f, 'hint') ?? f.hint;
}

private fieldText(f: CredentialField, part: 'label' | 'hint') {
const kind = this.provider()?.kind;
return (
(kind ? this.translated(`connections.fields.${kind}.${f.key}.${part}`) : null) ??
this.translated(`connections.fields.${f.key}.${part}`)
);
}

private translated(key: string): string | null {
Expand All @@ -698,7 +642,25 @@ export class ConnectionsComponent implements OnDestroy {
this.fields.set({});
this.problems.set([]);
this.expires.set('');
this.guideExpanded.set(!(this.connections.value() ?? []).some((c) => c.kind === p.kind));
this.provider.set(p);
this.guideToTop();
}

/** The dialog focuses its first field; with the guide open, start reading from step 1 instead. */
private guideToTop() {
if (!this.guideExpanded()) return;
setTimeout(
() => document.getElementById('connection-guide')?.scrollIntoView({ block: 'start' }),
120,
);
}

/** From a connection's error: new credentials, with the guide open to see what to change. */
protected fixCredentials(c: Connection) {
this.openCredentials(c);
this.guideExpanded.set(true);
this.guideToTop();
}

protected openCredentials(c: Connection) {
Expand All @@ -708,6 +670,7 @@ export class ConnectionsComponent implements OnDestroy {
this.fields.set({});
this.problems.set([]);
this.expires.set(c.credentialsExpireOn ?? '');
this.guideExpanded.set(false);
this.provider.set(p);
}

Expand Down
Loading