diff options
16 files changed, 574 insertions, 198 deletions
diff --git a/packages/taler-wallet-webextension/src/components/EditableText.tsx b/packages/taler-wallet-webextension/src/components/EditableText.tsx new file mode 100644 index 000000000..82983d13a --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/EditableText.tsx @@ -0,0 +1,69 @@ +/* + This file is part of GNU Taler + (C) 2019 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { VNode } from "preact"; +import { useRef, useState } from "preact/hooks"; +import { JSX } from "preact/jsx-runtime"; + +interface Props { + value: string; + onChange: (s: string) => Promise<void>; + label: string; + name: string; + description?: string; +} +export function EditableText({ name, value, onChange, label, description }: Props): JSX.Element { + const [editing, setEditing] = useState(false) + const ref = useRef<HTMLInputElement>() + let InputText; + if (!editing) { + InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}> + <p>{value}</p> + <button onClick={() => setEditing(true)}>edit</button> + </div> + } else { + InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}> + <input + value={value} + ref={ref} + type="text" + id={`text-${name}`} + /> + <button onClick={() => { onChange(ref.current.value).then(r => setEditing(false)) }}>confirm</button> + </div> + } + return ( + <div> + <label + htmlFor={`text-${name}`} + style={{ marginLeft: "0.5em", fontWeight: "bold" }} + > + {label} + </label> + <InputText /> + {description && <span + style={{ + color: "#383838", + fontSize: "smaller", + display: "block", + marginLeft: "2em", + }} + > + {description} + </span>} + </div> + ); +} diff --git a/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts new file mode 100644 index 000000000..e322c6727 --- /dev/null +++ b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "preact/hooks"; +import * as wxApi from "../wxApi"; + + +export interface BackupDeviceName { + name: string; + update: (s:string) => Promise<void> +} + + +export function useBackupDeviceName(): BackupDeviceName { + const [status, setStatus] = useState<BackupDeviceName>({ + name: '', + update: () => Promise.resolve() + }) + + useEffect(() => { + async function run() { + //create a first list of backup info by currency + const status = await wxApi.getBackupInfo() + + async function update(newName: string) { + await wxApi.setWalletDeviceId(newName) + setStatus(old => ({ ...old, name: newName })) + } + + setStatus({ name: status.deviceId, update }) + } + run() + }, []) + + return status +} + diff --git a/packages/taler-wallet-webextension/src/hooks/useProvidersByCurrency.ts b/packages/taler-wallet-webextension/src/hooks/useProvidersByCurrency.ts index 8c35705e1..09f61e468 100644 --- a/packages/taler-wallet-webextension/src/hooks/useProvidersByCurrency.ts +++ b/packages/taler-wallet-webextension/src/hooks/useProvidersByCurrency.ts @@ -1,37 +1,43 @@ import { Amounts } from "@gnu-taler/taler-util"; -import { ProviderInfo } from "@gnu-taler/taler-wallet-core"; +import { ProviderInfo, ProviderPaymentPaid, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; import { useEffect, useState } from "preact/hooks"; import * as wxApi from "../wxApi"; -export interface ProvidersByCurrency { - [s: string]: ProviderInfo | undefined -} export interface BackupStatus { deviceName: string; - providers: ProvidersByCurrency + providers: ProviderInfo[] +} + +function getStatusTypeOrder(t: ProviderPaymentStatus) { + return [ + ProviderPaymentType.InsufficientBalance, + ProviderPaymentType.TermsChanged, + ProviderPaymentType.Unpaid, + ProviderPaymentType.Paid, + ProviderPaymentType.Pending, + ].indexOf(t.type) +} + +function getStatusPaidOrder(a: ProviderPaymentPaid, b: ProviderPaymentPaid) { + return a.paidUntil.t_ms === 'never' ? -1 : + b.paidUntil.t_ms === 'never' ? 1 : + a.paidUntil.t_ms - b.paidUntil.t_ms } export function useBackupStatus(): BackupStatus | undefined { const [status, setStatus] = useState<BackupStatus | undefined>(undefined) + useEffect(() => { async function run() { //create a first list of backup info by currency const status = await wxApi.getBackupInfo() - const providers = status.providers.reduce((p, c) => { - if (c.terms) { - p[Amounts.parseOrThrow(c.terms.annualFee).currency] = c - } - return p - }, {} as ProvidersByCurrency) - - //add all the known currency with no backup info - const list = await wxApi.listKnownCurrencies() - const currencies = list.exchanges.map(e => e.name).concat(list.auditors.map(a => a.name)) - currencies.forEach(c => { - if (!providers[c]) { - providers[c] = undefined + + const providers = status.providers.sort((a, b) => { + if (a.paymentStatus.type === ProviderPaymentType.Paid && b.paymentStatus.type === ProviderPaymentType.Paid) { + return getStatusPaidOrder(a.paymentStatus, b.paymentStatus) } + return getStatusTypeOrder(a.paymentStatus) - getStatusTypeOrder(b.paymentStatus) }) setStatus({ deviceName: status.deviceId, providers }) diff --git a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx b/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx index 1bd431633..cd40d69a9 100644 --- a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx @@ -40,46 +40,117 @@ function createExample<Props>(Component: FunctionalComponent<Props>, props: Part return r } -export const Example = createExample(TestedComponent, { - deviceName: "somedevicename", - providers: { - ARS: { - "active": true, - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" +export const LotOfProviders = createExample(TestedComponent, { + providers: [{ + "active": true, + "syncProviderBaseUrl": "http://sync.taler:9967/", + "lastSuccessfulBackupTimestamp": { + "t_ms": 1625063925078 + }, + "paymentProposalIds": [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + ], + "paymentStatus": { + "type": ProviderPaymentType.Paid, + "paidUntil": { + "t_ms": 1656599921000 } }, - KUDOS: { - "active": false, - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, - }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" + "terms": { + "annualFee": "ARS:1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + }, { + "active": false, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.Unpaid, + }, + "terms": { + "annualFee": "KUDOS:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + },{ + "active": false, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.Unpaid, + }, + "terms": { + "annualFee": "KUDOS:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + },{ + "active": false, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.Unpaid, + }, + "terms": { + "annualFee": "KUDOS:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + },{ + "active": false, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.Unpaid, + }, + "terms": { + "annualFee": "KUDOS:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + },{ + "active": false, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.Unpaid, + }, + "terms": { + "annualFee": "KUDOS:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + }] +}); + + +export const OneProvider = createExample(TestedComponent, { + providers: [{ + "active": true, + "syncProviderBaseUrl": "http://sync.taler:9967/", + "lastSuccessfulBackupTimestamp": { + "t_ms": 1625063925078 + }, + "paymentProposalIds": [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + ], + "paymentStatus": { + "type": ProviderPaymentType.Paid, + "paidUntil": { + "t_ms": 1656599921000 } }, - USD: undefined, - EUR: undefined - } + "terms": { + "annualFee": "ARS:1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + }] }); +export const Empty = createExample(TestedComponent, { + providers: [] +}); + diff --git a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx b/packages/taler-wallet-webextension/src/popup/BackupPage.tsx index e0e41427b..91f1782cc 100644 --- a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BackupPage.tsx @@ -15,53 +15,64 @@ */ -import { Timestamp } from "@gnu-taler/taler-util"; +import { i18n, Timestamp } from "@gnu-taler/taler-util"; +import { ProviderInfo } from "@gnu-taler/taler-wallet-core"; import { formatDuration, intervalToDuration } from "date-fns"; import { JSX, VNode } from "preact"; -import { ProvidersByCurrency, useBackupStatus } from "../hooks/useProvidersByCurrency"; +import { useBackupStatus } from "../hooks/useProvidersByCurrency"; import { Pages } from "./popup"; -export function BackupPage(): VNode { +interface Props { + onAddProvider: () => void; +} + +export function BackupPage({ onAddProvider }: Props): VNode { const status = useBackupStatus() if (!status) { return <div>Loading...</div> } - return <BackupView deviceName={status.deviceName} providers={status.providers}/>; + return <BackupView providers={status.providers} onAddProvider={onAddProvider} />; } export interface ViewProps { - deviceName: string; - providers: ProvidersByCurrency + providers: ProviderInfo[], + onAddProvider: () => void; } -export function BackupView({ deviceName, providers }: ViewProps): VNode { +export function BackupView({ providers, onAddProvider }: ViewProps): VNode { return ( <div style={{ height: 'calc(320px - 34px - 16px)', overflow: 'auto' }}> - <div style={{ display: 'flex', flexDirection: 'row', width: '100%', justifyContent: 'space-between' }}> - <h2 style={{ width: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 10, marginBottom:10 }}> - {deviceName} - </h2> - <div style={{ flexDirection: 'row', marginTop: 'auto', marginBottom: 'auto' }}> - <button class="pure-button button-secondary">rename</button> - </div> + <div style={{ display: 'flex', flexDirection: 'column' }}> + <section style={{ flex: '1 0 auto', height: 'calc(320px - 34px - 34px - 16px)', overflow: 'auto' }}> + + {!!providers.length && <div> + {providers.map((provider, idx) => { + return <BackupLayout + status={provider.paymentStatus} + timestamp={provider.lastSuccessfulBackupTimestamp} + id={idx} + active={provider.active} + subtitle={provider.syncProviderBaseUrl} + title={provider.syncProviderBaseUrl} + /> + })} + </div>} + {!providers.length && <div> + There is not backup providers configured, add one with the button below + </div>} + + </section> + <footer style={{ marginTop: 'auto', display: 'flex', flexShrink: 0 }}> + <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'flex-end', display: 'flex' }}> + <button class="pure-button button-secondary" disabled={!providers.length} style={{ marginLeft: 5 }} onClick={onAddProvider}>{ + providers.length > 1 ? + <i18n.Translate>sync all now</i18n.Translate>: + <i18n.Translate>sync now</i18n.Translate> + }</button> + <button class="pure-button button-success" style={{ marginLeft: 5 }} onClick={onAddProvider}><i18n.Translate>add provider</i18n.Translate></button> + </div> + </footer> </div> - {Object.keys(providers).map((currency) => { - const provider = providers[currency] - if (!provider) { - return <BackupLayout - id={currency} - title={currency} - /> - } - return <BackupLayout - status={provider.paymentStatus} - timestamp={provider.lastSuccessfulBackupTimestamp} - id={currency} - active={provider.active} - subtitle={provider.syncProviderBaseUrl} - title={currency} - /> - })} </div> ) } @@ -70,7 +81,7 @@ interface TransactionLayoutProps { status?: any; timestamp?: Timestamp; title: string; - id: string; + id: number; subtitle?: string; active?: boolean; } @@ -96,13 +107,13 @@ function BackupLayout(props: TransactionLayoutProps): JSX.Element { <div style={{ display: "flex", flexDirection: "column", color: !props.active ? "gray" : undefined }} > - {dateStr && <div style={{ fontSize: "small", color: "gray" }}>{dateStr}</div>} - {!dateStr && <div style={{ fontSize: "small", color: "red" }}>never synced</div>} + <div style={{ fontVariant: "small-caps", fontSize: "x-large" }}> - <a href={Pages.provider_detail.replace(':currency', props.id)}><span>{props.title}</span></a> + <a href={Pages.provider_detail.replace(':pid', String(props.id))}><span>{props.title}</span></a> </div> - <div>{props.subtitle}</div> + {dateStr && <div style={{ fontSize: "small" }}>Last time synced: {dateStr}</div>} + {!dateStr && <div style={{ fontSize: "small", color: "red" }}>never synced</div>} </div> <div style={{ marginLeft: "auto", @@ -111,7 +122,7 @@ function BackupLayout(props: TransactionLayoutProps): JSX.Element { alignItems: "center", alignSelf: "center" }}> - <div style={{}}> + <div style={{ whiteSpace: 'nowrap' }}> {!props.status ? "missing" : ( props.status?.type === 'paid' ? daysUntil(props.status.paidUntil) : 'unpaid' )} diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx index 679a7ce43..f286870c1 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx @@ -40,7 +40,6 @@ function createExample<Props>(Component: FunctionalComponent<Props>, props: Part } export const DemoService = createExample(TestedComponent, { - currency: 'KUDOS', url: 'https://sync.demo.taler.net/', provider: { annual_fee: 'KUDOS:0.1', @@ -50,7 +49,6 @@ export const DemoService = createExample(TestedComponent, { }); export const FreeService = createExample(TestedComponent, { - currency: 'ARS', url: 'https://sync.taler:9667/', provider: { annual_fee: 'ARS:0', diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx index 7b8712eca..1e4a44df1 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx @@ -1,37 +1,60 @@ import { Amounts, BackupBackupProviderTerms, i18n } from "@gnu-taler/taler-util"; -import { privateDecrypt } from "crypto"; -import { add, addYears } from "date-fns"; -import { VNode } from "preact"; +import { Fragment, VNode } from "preact"; import { useState } from "preact/hooks"; import * as wxApi from "../wxApi"; -import ProviderAddConfirmProviderStories from "./ProviderAddConfirmProvider.stories"; interface Props { currency: string; } -export function ProviderAddPage({ currency }: Props): VNode { +function getJsonIfOk(r: Response) { + if (r.ok) { + return r.json() + } else { + if (r.status >= 400 && r.status < 500) { + throw new Error(`URL may not be right: (${r.status}) ${r.statusText}`) + } else { + throw new Error(`Try another server: (${r.status}) ${r.statusText || 'internal server error'}`) + } + } +} + + +export function ProviderAddPage({ }: Props): VNode { const [verifying, setVerifying] = useState<{ url: string, provider: BackupBackupProviderTerms } | undefined>(undefined) + const [readingTerms, setReadingTerms] = useState<boolean | undefined>(undefined) + const alreadyCheckedTheTerms = readingTerms === false + if (!verifying) { return <SetUrlView - currency={currency} onCancel={() => { setVerifying(undefined); }} onVerify={(url) => { - return fetch(url).then(r => r.json()) - .then((provider) => setVerifying({ url, provider })) + return fetch(`${url}/config`) + .catch(e => { throw new Error(`Network error`) }) + .then(getJsonIfOk) + .then((provider) => { setVerifying({ url, provider }); return undefined }) .catch((e) => e.message) }} /> } + if (readingTerms) { + return <TermsOfService + onCancel={() => setReadingTerms(undefined)} + onAccept={() => setReadingTerms(false)} + /> + } return <ConfirmProviderView provider={verifying.provider} - currency={currency} + termsChecked={alreadyCheckedTheTerms} url={verifying.url} onCancel={() => { setVerifying(undefined); }} + onShowTerms={() => { + setReadingTerms(true) + }} onConfirm={() => { wxApi.addBackupProvider(verifying.url).then(_ => history.go(-1)) }} @@ -39,33 +62,75 @@ export function ProviderAddPage({ currency }: Props): VNode { /> } +interface TermsOfServiceProps { + onCancel: () => void; + onAccept: () => void; +} + +function TermsOfService({ onCancel, onAccept }: TermsOfServiceProps) { + return <div style={{ display: 'flex', flexDirection: 'column' }}> + <section style={{ height: 'calc(320px - 34px - 34px - 16px)', overflow: 'auto' }}> + <div> + Here we will place the complete text of terms of service + </div> + </section> + <footer style={{ marginTop: 'auto', display: 'flex', flexShrink: 0 }}> + <button class="pure-button" onClick={onCancel}><i18n.Translate>cancel</i18n.Translate></button> + <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'flex-end', display: 'flex' }}> + <button class="pure-button" onClick={onAccept}><i18n.Translate>accept</i18n.Translate></button> + </div> + </footer> + </div> +} + export interface SetUrlViewProps { - currency: string, + initialValue?: string; onCancel: () => void; onVerify: (s: string) => Promise<string | undefined>; + withError?: string; } +import arrowDown from '../../static/img/chevron-down.svg'; -export function SetUrlView({ currency, onCancel, onVerify }: SetUrlViewProps) { - const [value, setValue] = useState<string>("") - const [error, setError] = useState<string | undefined>(undefined) +export function SetUrlView({ initialValue, onCancel, onVerify, withError }: SetUrlViewProps) { + const [value, setValue] = useState<string>(initialValue || "") + const [error, setError] = useState<string | undefined>(withError) + const [showErrorDetail, setShowErrorDetail] = useState(false); return <div style={{ display: 'flex', flexDirection: 'column' }}> <section style={{ height: 'calc(320px - 34px - 34px - 16px)', overflow: 'auto' }}> <div> - Add backup provider for storing <b>{currency}</b> + Add backup provider for saving coins </div> - {error && <div class="errorbox" style={{ marginTop: 10 }} > - <p>{error}</p> - </div>} <h3>Backup provider URL</h3> - <input style={{ width: 'calc(100% - 8px)' }} value={value} onChange={(e) => setValue(e.currentTarget.value)} /> + <div style={{ width: '3em', display: 'inline-block' }}>https://</div> + <input style={{ width: 'calc(100% - 8px - 4em)', marginLeft: 5 }} value={value} onChange={(e) => setValue(e.currentTarget.value)} /> <p> Backup providers may charge for their service </p> + {error && <Fragment> + <div class="errorbox" style={{ marginTop: 10 }} > + <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'space-between', display: 'flex' }}> + <p style={{ alignSelf: 'center' }}>Could not get provider information</p> + <p> + <button style={{ fontSize: '100%', padding: 0, height: 28, width: 28 }} onClick={() => { setShowErrorDetail(v => !v) }} > + <img style={{ height: '1.5em' }} src={arrowDown} /> + </button> + </p> + </div> + {showErrorDetail && <div>{error}</div>} + </div> + </Fragment> + } </section> <footer style={{ marginTop: 'auto', display: 'flex', flexShrink: 0 }}> <button class="pure-button" onClick={onCancel}><i18n.Translate>cancel</i18n.Translate></button> <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'flex-end', display: 'flex' }}> - <button class="pure-button button-secondary" style={{ marginLeft: 5 }} onClick={() => onVerify(value).then(r => r ? setError(r) : undefined)}><i18n.Translate>verify service terms</i18n.Translate></button> + <button class="pure-button button-secondary" style={{ marginLeft: 5 }} + disabled={!value} + onClick={() => { + let url = value.startsWith('http://') || value.startsWith('https://') ? value : `https://${value}` + url = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + return onVerify(url).then(r => r ? setError(r) : undefined) + }}><i18n.Translate>next</i18n.Translate></button> </div> </footer> </div> @@ -73,19 +138,16 @@ export function SetUrlView({ currency, onCancel, onVerify }: SetUrlViewProps) { export interface ConfirmProviderViewProps { provider: BackupBackupProviderTerms, - currency: string, url: string, onCancel: () => void; - onConfirm: () => void + onConfirm: () => void; + onShowTerms: () => void; + termsChecked: boolean; } -export function ConfirmProviderView({ url, provider, currency, onCancel, onConfirm }: ConfirmProviderViewProps) { +export function ConfirmProviderView({ url, termsChecked, onShowTerms, provider, onCancel, onConfirm }: ConfirmProviderViewProps) { return <div style={{ display: 'flex', flexDirection: 'column' }}> - <section style={{ height: 'calc(320px - 34px - 34px - 16px)', overflow: 'auto' }}> - <div> - Verify provider service terms for storing <b>{currency}</b> - </div> - <h3>{url}</h3> + <div>Verify provider service terms for <b>{url}</b> backup provider</div> <p> {Amounts.isZero(provider.annual_fee) ? 'free of charge' : provider.annual_fee} for a year of backup service </p> @@ -98,9 +160,14 @@ export function ConfirmProviderView({ url, provider, currency, onCancel, onConfi <i18n.Translate>cancel</i18n.Translate> </button> <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'flex-end', display: 'flex' }}> - <button class="pure-button button-success" style={{ marginLeft: 5 }} onClick={onConfirm}> - <i18n.Translate>confirm</i18n.Translate> - </button> + {termsChecked ? + <button class="pure-button button-success" style={{ marginLeft: 5 }} onClick={onConfirm}> + <i18n.Translate>confirm</i18n.Translate> + </button> : + <button class="pure-button button-success" style={{ marginLeft: 5 }} onClick={onShowTerms}> + <i18n.Translate>review terms</i18n.Translate> + </button> + } </div> </footer> </div> diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx index 8b9075165..dfee115bb 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core'; import { FunctionalComponent } from 'preact'; import { SetUrlView as TestedComponent } from './ProviderAddPage'; @@ -40,7 +39,21 @@ function createExample<Props>(Component: FunctionalComponent<Props>, props: Part return r } -export const SetUrl = createExample(TestedComponent, { - currency: 'ARS', +export const Initial = createExample(TestedComponent, { }); +export const WithValue = createExample(TestedComponent, { + initialValue: 'sync.demo.taler.net' +}); + +export const WithConnectionError = createExample(TestedComponent, { + withError: 'Network error' +}); + +export const WithClientError = createExample(TestedComponent, { + withError: 'URL may not be right: (404) Not Found' +}); + +export const WithServerError = createExample(TestedComponent, { + withError: 'Try another server: (500) Internal Server Error' +}); diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx index 01c0a5f05..480d7b1a4 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx @@ -40,12 +40,7 @@ function createExample<Props>(Component: FunctionalComponent<Props>, props: Part return r } -export const NotDefined = createExample(TestedComponent, { - currency: 'ARS', -}); - export const Active = createExample(TestedComponent, { - currency: 'ARS', info: { "active": true, "syncProviderBaseUrl": "http://sync.taler:9967/", @@ -62,7 +57,7 @@ export const Active = createExample(TestedComponent, { } }, "terms": { - "annualFee": "ARS:1", + "annualFee": "EUR:1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -70,7 +65,6 @@ export const Active = createExample(TestedComponent, { }); export const ActiveErrorSync = createExample(TestedComponent, { - currency: 'ARS', info: { "active": true, "syncProviderBaseUrl": "http://sync.taler:9967/", @@ -96,7 +90,7 @@ export const ActiveErrorSync = createExample(TestedComponent, { message: 'message' }, "terms": { - "annualFee": "ARS:1", + "annualFee": "EUR:1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -104,7 +98,6 @@ export const ActiveErrorSync = createExample(TestedComponent, { }); export const ActiveBackupProblemUnreadable = createExample(TestedComponent, { - currency: 'ARS', info: { "active": true, "syncProviderBaseUrl": "http://sync.taler:9967/", @@ -124,7 +117,7 @@ export const ActiveBackupProblemUnreadable = createExample(TestedComponent, { type: 'backup-unreadable' }, "terms": { - "annualFee": "ARS:1", + "annualFee": "EUR:1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -132,7 +125,6 @@ export const ActiveBackupProblemUnreadable = createExample(TestedComponent, { }); export const ActiveBackupProblemDevice = createExample(TestedComponent, { - currency: 'ARS', info: { "active": true, "syncProviderBaseUrl": "http://sync.taler:9967/", @@ -157,7 +149,7 @@ export const ActiveBackupProblemDevice = createExample(TestedComponent, { } }, "terms": { - "annualFee": "ARS:1", + "annualFee": "EUR:1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -165,7 +157,6 @@ export const ActiveBackupProblemDevice = createExample(TestedComponent, { }); export const InactiveUnpaid = createExample(TestedComponent, { - currency: 'ARS', info: { "active": false, "syncProviderBaseUrl": "http://sync.demo.taler.net/", @@ -174,7 +165,7 @@ export const InactiveUnpaid = createExample(TestedComponent, { "type": ProviderPaymentType.Unpaid, }, "terms": { - "annualFee": "ARS:0.1", + "annualFee": "EUR:0.1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -182,7 +173,6 @@ export const InactiveUnpaid = createExample(TestedComponent, { }); export const InactiveInsufficientBalance = createExample(TestedComponent, { - currency: 'ARS', info: { "active": false, "syncProviderBaseUrl": "http://sync.demo.taler.net/", @@ -191,7 +181,7 @@ export const InactiveInsufficientBalance = createExample(TestedComponent, { "type": ProviderPaymentType.InsufficientBalance, }, "terms": { - "annualFee": "ARS:0.1", + "annualFee": "EUR:0.1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -199,7 +189,6 @@ export const InactiveInsufficientBalance = createExample(TestedComponent, { }); export const InactivePending = createExample(TestedComponent, { - currency: 'ARS', info: { "active": false, "syncProviderBaseUrl": "http://sync.demo.taler.net/", @@ -208,7 +197,7 @@ export const InactivePending = createExample(TestedComponent, { "type": ProviderPaymentType.Pending, }, "terms": { - "annualFee": "ARS:0.1", + "annualFee": "EUR:0.1", "storageLimitInMegabytes": 16, "supportedProtocolVersion": "0.0" } @@ -216,3 +205,32 @@ export const InactivePending = createExample(TestedComponent, { }); +export const ActiveTermsChanged = createExample(TestedComponent, { + info: { + "active": true, + "syncProviderBaseUrl": "http://sync.demo.taler.net/", + "paymentProposalIds": [], + "paymentStatus": { + "type": ProviderPaymentType.TermsChanged, + paidUntil: { + t_ms: 1656599921000 + }, + newTerms: { + "annualFee": "EUR:10", + "storageLimitInMegabytes": 8, + "supportedProtocolVersion": "0.0" + }, + oldTerms: { + "annualFee": "EUR:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + }, + "terms": { + "annualFee": "EUR:0.1", + "storageLimitInMegabytes": 16, + "supportedProtocolVersion": "0.0" + } + } +}); + diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx index 59e6cda1b..1b8abf44d 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx @@ -16,7 +16,8 @@ import { BackupBackupProviderTerms, i18n, Timestamp } from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { ProviderInfo, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { ContractTermsUtil } from "@gnu-taler/taler-wallet-core/src/util/contractTerms"; import { formatDuration, intervalToDuration, format } from "date-fns"; import { Fragment, VNode } from "preact"; import { useRef, useState } from "preact/hooks"; @@ -24,42 +25,45 @@ import { useBackupStatus } from "../hooks/useProvidersByCurrency"; import * as wxApi from "../wxApi"; interface Props { - currency: string; - onAddProvider: (c: string) => void; + pid: string; onBack: () => void; } -export function ProviderDetailPage({ currency, onAddProvider, onBack }: Props): VNode { +export function ProviderDetailPage({ pid, onBack }: Props): VNode { const status = useBackupStatus() if (!status) { return <div>Loading...</div> } - const info = status.providers[currency]; - return <ProviderView currency={currency} info={info} + const idx = parseInt(pid, 10) + if (Number.isNaN(idx) || !(status.providers[idx])) { + onBack() + return <div /> + } + const info = status.providers[idx]; + return <ProviderView info={info} onSync={() => { null }} onDelete={() => { null }} onBack={onBack} - onAddProvider={() => onAddProvider(currency)} + onExtend={() => { null }} />; } export interface ViewProps { - currency: string; - info?: ProviderInfo; + info: ProviderInfo; onDelete: () => void; onSync: () => void; onBack: () => void; - onAddProvider: () => void; + onExtend: () => void; } -export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddProvider }: ViewProps): VNode { +export function ProviderView({ info, onDelete, onSync, onBack, onExtend }: ViewProps): VNode { function Footer() { return <footer style={{ marginTop: 'auto', display: 'flex', flexShrink: 0 }}> <button class="pure-button" onClick={onBack}><i18n.Translate>back</i18n.Translate></button> <div style={{ width: '100%', flexDirection: 'row', justifyContent: 'flex-end', display: 'flex' }}> - {info && <button class="pure-button button-destructive" onClick={onDelete}><i18n.Translate>remove</i18n.Translate></button>} - {info && <button class="pure-button button-secondary" style={{ marginLeft: 5 }} onClick={onSync}><i18n.Translate>sync now</i18n.Translate></button>} - {!info && <button class="pure-button button-success" style={{ marginLeft: 5 }} onClick={onAddProvider}><i18n.Translate>add provider</i18n.Translate></button>} + {info && <button class="pure-button button-destructive" disabled onClick={onDelete}><i18n.Translate>remove</i18n.Translate></button>} + {info && <button class="pure-button button-secondary" disabled style={{ marginLeft: 5 }} onClick={onExtend}><i18n.Translate>extend</i18n.Translate></button>} + {info && <button class="pure-button button-secondary" disabled style={{ marginLeft: 5 }} onClick={onSync}><i18n.Translate>sync now</i18n.Translate></button>} </div> </footer> } @@ -67,7 +71,7 @@ export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddPr if (info?.lastError) { return <Fragment> <div class="errorbox" style={{ marginTop: 10 }} > - <div style={{ height: 0, textAlign: 'right', color: 'gray', fontSize: 'small' }}>{!info.lastAttemptedBackupTimestamp || info.lastAttemptedBackupTimestamp.t_ms === 'never' ? 'never' : format(new Date(info.lastAttemptedBackupTimestamp.t_ms), 'dd/MM/yyyy HH:mm:ss')}</div> + <div style={{ height: 0, textAlign: 'right', color: 'gray', fontSize: 'small' }}>last time tried {!info.lastAttemptedBackupTimestamp || info.lastAttemptedBackupTimestamp.t_ms === 'never' ? 'never' : format(new Date(info.lastAttemptedBackupTimestamp.t_ms), 'dd/MM/yyyy HH:mm:ss')}</div> <p>{info.lastError.hint}</p> </div> </Fragment> @@ -76,7 +80,7 @@ export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddPr switch (info.backupProblem.type) { case "backup-conflicting-device": return <div class="errorbox" style={{ marginTop: 10 }}> - <p>There is another backup from <b>{info.backupProblem.otherDeviceId}</b></p> + <p>There is conflict with another backup from <b>{info.backupProblem.otherDeviceId}</b></p> </div> case "backup-unreadable": return <div class="errorbox" style={{ marginTop: 10 }}> @@ -84,7 +88,7 @@ export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddPr </div> default: return <div class="errorbox" style={{ marginTop: 10 }}> - <p>Unkown backup problem: {JSON.stringify(info.backupProblem)}</p> + <p>Unknown backup problem: {JSON.stringify(info.backupProblem)}</p> </div> } } @@ -110,6 +114,28 @@ export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddPr return undefined } + function descriptionByStatus(status: ProviderPaymentStatus | undefined) { + if (!status) return '' + switch (status.type) { + case ProviderPaymentType.InsufficientBalance: + return 'no enough balance to make the payment' + case ProviderPaymentType.Unpaid: + return 'not pay yet' + case ProviderPaymentType.Paid: + case ProviderPaymentType.TermsChanged: + if (status.paidUntil.t_ms === 'never') { + return 'service paid.' + } else { + return `service paid until ${format(status.paidUntil.t_ms, 'yyyy/MM/dd HH:mm:ss')}` + } + case ProviderPaymentType.Pending: + return '' + default: + break; + } + return undefined + } + return ( <div style={{ height: 'calc(320px - 34px - 16px)', overflow: 'auto' }}> <style>{` @@ -120,18 +146,49 @@ export function ProviderView({ currency, info, onDelete, onSync, onBack, onAddPr <div style={{ display: 'flex', flexDirection: 'column' }}> <section style={{ flex: '1 0 auto', height: 'calc(320px - 34px - 34px - 16px)', overflow: 'auto' }}> <span style={{ padding: 5, display: 'inline-block', backgroundColor: colorByStatus(info?.paymentStatus.type), borderRadius: 5, color: 'white' }}>{info?.paymentStatus.type}</span> - {info && <span style={{ float: "right", fontSize: "small", color: "gray", padding: 5 }}> + {/* {info && <span style={{ float: "right", fontSize: "small", color: "gray", padding: 5 }}> From <b>{info.syncProviderBaseUrl}</b> - </span>} + </span>} */} + {info && <div style={{ float: 'right', fontSize: "large", padding: 5 }}>{info.terms?.annualFee} / year</div>} <Error /> + <h3>{info?.syncProviderBaseUrl}</h3> <div style={{ display: "flex", flexDirection: "row", justifyContent: "space-between", }}> - <h1>{currency}</h1> - {info && <div style={{ marginTop: 'auto', marginBottom: 'auto' }}>{info.terms?.annualFee} / year</div>} + <div>{daysSince(info?.lastSuccessfulBackupTimestamp)} </div> </div> - <div>{daysSince(info?.lastSuccessfulBackupTimestamp)} </div> + <p>{descriptionByStatus(info?.paymentStatus)}</p> + + {info?.paymentStatus.type === ProviderPaymentType.TermsChanged && <div> + <p>terms has changed, extending the service will imply accepting the new terms of service</p> + <table> + <thead> + <tr> + <td></td> + <td>old</td> + <td> -></td> + <td>new</td> + </tr> + </thead> + <tbody> + + <tr> + <td>fee</td> + <td>{info.paymentStatus.oldTerms.annualFee}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.annualFee}</td> + </tr> + <tr> + <td>storage</td> + <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td> + </tr> + </tbody> + </table> + </div>} + </section> <Footer /> </div> diff --git a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx b/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx index b6d852d52..07e1538b7 100644 --- a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx @@ -19,13 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { - PaymentStatus, - TransactionCommon, TransactionDeposit, TransactionPayment, - TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, - TransactionWithdrawal, - WithdrawalType -} from '@gnu-taler/taler-util'; import { FunctionalComponent } from 'preact'; import { SettingsView as TestedComponent } from './Settings'; @@ -33,9 +26,7 @@ export default { title: 'popup/settings', component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, + setDeviceName: () => Promise.resolve(), } }; @@ -46,9 +37,14 @@ function createExample<Props>(Component: FunctionalComponent<Props>, props: Part return r } -export const AllOff = createExample(TestedComponent, {}); +export const AllOff = createExample(TestedComponent, { + deviceName: 'this-is-the-device-name', + setDeviceName: () => Promise.resolve(), +}); export const OneChecked = createExample(TestedComponent, { + deviceName: 'this-is-the-device-name', permissionsEnabled: true, + setDeviceName: () => Promise.resolve(), }); diff --git a/packages/taler-wallet-webextension/src/popup/Settings.tsx b/packages/taler-wallet-webextension/src/popup/Settings.tsx index 0a57092bb..d8cd04380 100644 --- a/packages/taler-wallet-webextension/src/popup/Settings.tsx +++ b/packages/taler-wallet-webextension/src/popup/Settings.tsx @@ -17,40 +17,57 @@ import { VNode } from "preact"; import { Checkbox } from "../components/Checkbox"; +import { EditableText } from "../components/EditableText"; import { useDevContext } from "../context/useDevContext"; +import { useBackupDeviceName } from "../hooks/useBackupDeviceName"; import { useExtendedPermissions } from "../hooks/useExtendedPermissions"; export function SettingsPage(): VNode { const [permissionsEnabled, togglePermissions] = useExtendedPermissions(); const { devMode, toggleDevMode } = useDevContext() - return <SettingsView + const { name, update } = useBackupDeviceName() + return <SettingsView + deviceName={name} setDeviceName={update} permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions} developerMode={devMode} toggleDeveloperMode={toggleDevMode} />; } export interface ViewProps { + deviceName: string; + setDeviceName: (s: string) => Promise<void>; permissionsEnabled: boolean; togglePermissions: () => void; developerMode: boolean; toggleDeveloperMode: () => void; } -export function SettingsView({permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode}: ViewProps): VNode { +export function SettingsView({ deviceName, setDeviceName, permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode }: ViewProps): VNode { return ( <div> - <h2>Permissions</h2> - <Checkbox label="Automatically open wallet based on page content" - name="perm" - description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" - enabled={permissionsEnabled} onToggle={togglePermissions} - /> - <h2>Config</h2> - <Checkbox label="Developer mode" - name="devMode" - description="(More options and information useful for debugging)" - enabled={developerMode} onToggle={toggleDeveloperMode} - /> + <section style={{ height: 'calc(320px - 34px - 16px)', overflow: 'auto' }}> + + <h2>Wallet</h2> + <EditableText + value={deviceName} + onChange={setDeviceName} + name="device-id" + label="Device name" + description="(This is how you will recognize the wallet in the backup provider)" + /> + <h2>Permissions</h2> + <Checkbox label="Automatically open wallet based on page content" + name="perm" + description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" + enabled={permissionsEnabled} onToggle={togglePermissions} + /> + <h2>Config</h2> + <Checkbox label="Developer mode" + name="devMode" + description="(More options and information useful for debugging)" + enabled={developerMode} onToggle={toggleDeveloperMode} + /> + </section> </div> ) }
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/popup/popup.tsx b/packages/taler-wallet-webextension/src/popup/popup.tsx index 2ed9dbab9..f7b3cec94 100644 --- a/packages/taler-wallet-webextension/src/popup/popup.tsx +++ b/packages/taler-wallet-webextension/src/popup/popup.tsx @@ -36,8 +36,8 @@ export enum Pages { backup = '/backup', history = '/history', transaction = '/transaction/:tid', - provider_detail = '/provider/:currency', - provider_add = '/provider/:currency/add', + provider_detail = '/provider/:pid', + provider_add = '/provider/add', } interface TabProps { @@ -61,7 +61,6 @@ function Tab(props: TabProps): JSX.Element { export function WalletNavBar() { const { devMode } = useDevContext() return <Match>{({ path }: any) => { - console.log("current", path) return ( <div class="nav" id="header"> <Tab target="/balance" current={path}>{i18n.str`Balance`}</Tab> diff --git a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx index d73b3566b..80a2a2bd3 100644 --- a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx +++ b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx @@ -99,8 +99,16 @@ function Application() { <Route path={Pages.settings} component={SettingsPage} /> <Route path={Pages.dev} component={DeveloperPage} /> <Route path={Pages.history} component={HistoryPage} /> - <Route path={Pages.backup} component={BackupPage} /> - <Route path={Pages.provider_detail} component={ProviderDetailPage} /> + <Route path={Pages.backup} component={BackupPage} + onAddProvider={() => { + route(Pages.provider_add) + }} + /> + <Route path={Pages.provider_detail} component={ProviderDetailPage} + onBack={() => { + route(Pages.backup) + }} + /> <Route path={Pages.provider_add} component={ProviderAddPage} /> <Route path={Pages.transaction} component={TransactionPage} /> <Route default component={Redirect} to={Pages.balance} /> diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 393c41102..db440e913 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -37,6 +37,7 @@ import { AcceptTipRequest, DeleteTransactionRequest, RetryTransactionRequest, + SetWalletDeviceIdRequest, } from "@gnu-taler/taler-util"; import { AddBackupProviderRequest, BackupProviderState, OperationFailedError } from "@gnu-taler/taler-wallet-core"; import { BackupInfo } from "@gnu-taler/taler-wallet-core"; @@ -179,13 +180,17 @@ export function addBackupProvider(backupProviderBaseUrl: string): Promise<void> } as AddBackupProviderRequest) } +export function setWalletDeviceId(walletDeviceId: string): Promise<void> { + return callBackend("setWalletDeviceId", { + walletDeviceId + } as SetWalletDeviceIdRequest) +} + export function syncAllProviders(): Promise<void> { return callBackend("runBackupCycle", {}) } - - /** * Retry a transaction * @param transactionId diff --git a/packages/taler-wallet-webextension/static/img/chevron-down.svg b/packages/taler-wallet-webextension/static/img/chevron-down.svg new file mode 100644 index 000000000..36adbc1c6 --- /dev/null +++ b/packages/taler-wallet-webextension/static/img/chevron-down.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="92px" height="92px" viewBox="0 0 92 92" enable-background="new 0 0 92 92" xml:space="preserve"> +<path id="XMLID_467_" d="M46,63c-1.1,0-2.1-0.4-2.9-1.2l-25-26c-1.5-1.6-1.5-4.1,0.1-5.7c1.6-1.5,4.1-1.5,5.7,0.1l22.1,23l22.1-23 + c1.5-1.6,4.1-1.6,5.7-0.1c1.6,1.5,1.6,4.1,0.1,5.7l-25,26C48.1,62.6,47.1,63,46,63z"/> +</svg> |