diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/ExchangeSelection')
4 files changed, 604 insertions, 170 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts index 4b28904fb..9603b3d2c 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts @@ -15,15 +15,15 @@ */ import { - FeeDescription, - FeeDescriptionPair, - AbsoluteTime, + DenomOperationMap, ExchangeFullDetails, - OperationMap, - ExchangeListItem, + ExchangeListItem, FeeDescriptionPair } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { + State as SelectExchangeState +} from "../../hooks/useSelectedExchange.js"; import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; import { compose, StateViewMap } from "../../utils/index.js"; import * as wxApi from "../../wxApi.js"; @@ -32,7 +32,7 @@ import { ComparingView, ErrorLoadingView, NoExchangesView, - ReadyView, + ReadyView } from "./views.js"; export interface Props { @@ -41,9 +41,6 @@ export interface Props { onCancel: () => Promise<void>; onSelection: (exchange: string) => Promise<void>; } -import { - State as SelectExchangeState -} from "../../hooks/useSelectedExchange.js"; export type State = | State.Loading @@ -71,13 +68,12 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; - timeline: OperationMap<FeeDescription[]>; onClose: ButtonHandler; } export interface Comparing extends BaseInfo { status: "comparing"; - pairTimeline: OperationMap<FeeDescriptionPair[]>; + pairTimeline: DenomOperationMap<FeeDescriptionPair[]>; onReset: ButtonHandler; onSelect: ButtonHandler; } diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts index 0279f6514..954e52239 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -14,8 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { FeeDescription, OperationMap } from "@gnu-taler/taler-util"; -import { createDenominationPairTimeline } from "@gnu-taler/taler-wallet-core"; +import { DenomOperationMap, FeeDescription } from "@gnu-taler/taler-util"; +import { createPairTimeline } from "@gnu-taler/taler-wallet-core"; import { useState } from "preact/hooks"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import * as wxApi from "../../wxApi.js"; @@ -94,27 +94,26 @@ export function useComponentState( onClick: onCancel, }, selected, - timeline: selected.feesDescription, }; } - const pairTimeline: OperationMap<FeeDescription[]> = { - deposit: createDenominationPairTimeline( - selected.feesDescription.deposit, - original.feesDescription.deposit, + const pairTimeline: DenomOperationMap<FeeDescription[]> = { + deposit: createPairTimeline( + selected.denomFees.deposit, + original.denomFees.deposit, ), - refresh: createDenominationPairTimeline( - selected.feesDescription.refresh, - original.feesDescription.refresh, + refresh: createPairTimeline( + selected.denomFees.refresh, + original.denomFees.refresh, ), - refund: createDenominationPairTimeline( - selected.feesDescription.refund, - original.feesDescription.refund, - ), - withdraw: createDenominationPairTimeline( - selected.feesDescription.withdraw, - original.feesDescription.withdraw, + refund: createPairTimeline( + selected.denomFees.refund, + original.denomFees.refund, ), + withdraw: createPairTimeline( + selected.denomFees.withdraw, + original.denomFees.withdraw, + ) }; return { diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx index 43a147e28..38b63e615 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx @@ -28,71 +28,72 @@ export default { export const Bitcoin1 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { "0": "https://exchange.taler.ar" }, + value: "0", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const Bitcoin2 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange.taler.ar": "https://exchange.taler.ar", + "https://exchange-btc.taler.ar": "https://exchange-btc.taler.ar", + }, + value: "https://exchange.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); + export const Kudos1 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const Kudos2 = createExample(ReadyView, { exchanges: { - list: { "http://exchange": "http://exchange" }, - value: "http://exchange", + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + "https://exchange-kudos2.taler.ar": "https://exchange-kudos2.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", }, selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], } as any, onClose: {}, - timeline: { - deposit: [], - refresh: [], - refund: [], - withdraw: [], - }, }); export const ComparingBitcoin = createExample(ComparingView, { exchanges: { @@ -102,6 +103,9 @@ export const ComparingBitcoin = createExample(ComparingView, { selected: { currency: "BITCOINBTC", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], } as any, onReset: {}, onSelect: {}, @@ -121,6 +125,9 @@ export const ComparingKudos = createExample(ComparingView, { selected: { currency: "KUDOS", auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], } as any, onReset: {}, onSelect: {}, @@ -132,3 +139,400 @@ export const ComparingKudos = createExample(ComparingView, { withdraw: [], }, }); + +function timelineExample() { + return { + deposit: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refresh: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refund: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + withdraw: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wad: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wire: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + closing: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + }; +} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx index 47554bfcd..6b753e215 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx @@ -31,9 +31,7 @@ import { useTranslationContext } from "../../context/translation.js"; import { Button } from "../../mui/Button.js"; import arrowDown from "../../svg/chevron-down.svg"; import { State } from "./index.js"; -import { - State as SelectExchangeState -} from "../../hooks/useSelectedExchange.js"; +import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; const ButtonGroup = styled.div` & > button { @@ -59,7 +57,7 @@ const FeeDescriptionTable = styled.table` } td.value { text-align: right; - width: 1%; + width: 15%; white-space: nowrap; } td.icon { @@ -109,26 +107,28 @@ export function ErrorLoadingView({ error }: State.LoadingUriError): VNode { return ( <LoadingError - title={<i18n.Translate>Could not load tip status</i18n.Translate>} + title={<i18n.Translate>Could not load exchange fees</i18n.Translate>} error={error} /> ); } - - -export function NoExchangesView({currency}: SelectExchangeState.NoExchange): VNode { +export function NoExchangesView({ + currency, +}: SelectExchangeState.NoExchange): VNode { const { i18n } = useTranslationContext(); if (!currency) { return ( <div> <i18n.Translate>could not find any exchange</i18n.Translate> </div> - ); + ); } return ( <div> - <i18n.Translate>could not find any exchange for the currency {currency}</i18n.Translate> + <i18n.Translate> + could not find any exchange for the currency {currency} + </i18n.Translate> </div> ); } @@ -356,7 +356,6 @@ export function ReadyView({ exchanges, selected, onClose, - timeline, }: State.Ready): VNode { const { i18n } = useTranslationContext(); @@ -365,7 +364,10 @@ export function ReadyView({ <h2> <i18n.Translate>Service fee description</i18n.Translate> </h2> - + <p> + All fee indicated below are in the same and only currency the exchange + works. + </p> <section> <div style={{ @@ -375,21 +377,27 @@ export function ReadyView({ justifyContent: "space-between", }} > - <p> - <Input> - <SelectList - label={ - <i18n.Translate> - Select {selected.currency} exchange - </i18n.Translate> - } - list={exchanges.list} - name="lang" - value={exchanges.value} - onChange={exchanges.onChange} - /> - </Input> - </p> + {Object.keys(exchanges.list).length === 1 ? ( + <Fragment> + <p>Exchange: {selected.exchangeBaseUrl}</p> + </Fragment> + ) : ( + <p> + <Input> + <SelectList + label={ + <i18n.Translate> + Select {selected.currency} exchange + </i18n.Translate> + } + list={exchanges.list} + name="lang" + value={exchanges.value} + onChange={exchanges.onChange} + /> + </Input> + </p> + )} <Button variant="outlined" onClick={onClose.onClick}> <i18n.Translate>Close</i18n.Translate> </Button> @@ -411,17 +419,26 @@ export function ReadyView({ <table> <tr> <td> - <i18n.Translate>currency</i18n.Translate> + <i18n.Translate>Currency</i18n.Translate> + </td> + <td> + <b>{selected.currency}</b> </td> - <td>{selected.currency}</td> </tr> </table> </section> <section> <h2> - <i18n.Translate>Operations</i18n.Translate> + <i18n.Translate>Coin operations</i18n.Translate> </h2> <p> + <i18n.Translate> + Every operation in this section may be different by denomination + value and is valid for a period of time. The exchange will charge + the indicated amount every time a coin is used in such operation. + </i18n.Translate> + </p> + <p> <i18n.Translate>Deposits</i18n.Translate> </p> <FeeDescriptionTable> @@ -440,7 +457,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.deposit} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.deposit} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable> <p> @@ -462,7 +482,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.withdraw} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.withdraw} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable> <p> @@ -484,7 +507,10 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.refund} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.refund} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> </FeeDescriptionTable>{" "} <p> @@ -506,53 +532,81 @@ export function ReadyView({ </tr> </thead> <tbody> - <RenderFeeDescriptionByValue first={timeline.refresh} /> + <RenderFeeDescriptionByValue + list={selected.denomFees.refresh} + sorting={(a, b) => Number(a) - Number(b)} + /> </tbody> - </FeeDescriptionTable>{" "} + </FeeDescriptionTable> </section> <section> - <table> + <h2> + <i18n.Translate>Transfer operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + {Object.entries(selected.transferFees).map(([type, fees], idx) => { + return ( + <Fragment key={idx}> + <p>{type}</p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Operation</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue list={fees} /> + </tbody> + </FeeDescriptionTable> + </Fragment> + ); + })} + </section> + <section> + <h2> + <i18n.Translate>Wallet operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + <FeeDescriptionTable> <thead> <tr> - <td> - <i18n.Translate>Wallet operations</i18n.Translate> - </td> - <td> + <th> </th> + <th> + <i18n.Translate>Feature</i18n.Translate> + </th> + <th class="fee"> <i18n.Translate>Fee</i18n.Translate> - </td> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> </tr> </thead> <tbody> - <tr> - <td>history(i) </td> - <td>0.1</td> - </tr> - <tr> - <td>kyc (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>account (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>purse (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>wire SEPA (i) </td> - <td>0.1</td> - </tr> - <tr> - <td>closing SEPA(i) </td> - <td>0.1</td> - </tr> - <tr> - <td>wad SEPA (i) </td> - <td>0.1</td> - </tr> + <RenderFeeDescriptionByValue list={selected.globalFees} /> </tbody> - </table> + </FeeDescriptionTable> </section> <section> <ButtonGroup> @@ -579,7 +633,7 @@ function FeeDescriptionRowsGroup({ <tr key={idx} class="value" - data-hasMore={!hasMoreInfo} + data-hasMore={hasMoreInfo} data-main={main} data-hidden={!main && !expanded} onClick={() => setExpand((p) => !p)} @@ -594,9 +648,7 @@ function FeeDescriptionRowsGroup({ /> ) : undefined} </td> - <td class="value"> - {main ? <Amount value={info.value} hideCurrency /> : ""} - </td> + <td class="value">{main ? info.group : ""}</td> {info.fee ? ( <td class="fee">{<Amount value={info.fee} hideCurrency />}</td> ) : undefined} @@ -621,7 +673,7 @@ function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode { <tr key={idx} class="value" - data-hasMore={!hasMoreInfo} + data-hasMore={hasMoreInfo} data-main={main} data-hidden={!main && !expanded} onClick={() => setExpand((p) => !p)} @@ -636,9 +688,7 @@ function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode { /> ) : undefined} </td> - <td class="value"> - {main ? <Amount value={info.value} hideCurrency /> : ""} - </td> + <td class="value">{main ? info.group : ""}</td> {info.left ? ( <td class="fee">{<Amount value={info.left} hideCurrency />}</td> ) : ( @@ -673,7 +723,7 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { const next = idx >= list.length - 1 ? undefined : list[idx + 1]; const nextIsMoreInfo = - next !== undefined && Amounts.cmp(next.value, info.value) === 0; + next !== undefined && next.group === info.group; prev.rows.push(info); @@ -681,7 +731,7 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { return prev; } - prev.rows = []; + // prev.rows = []; prev.views.push(<FeePairRowsGroup infos={prev.rows} />); return prev; }, @@ -701,36 +751,21 @@ function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode { * @returns */ function RenderFeeDescriptionByValue({ - first, + list, + sorting, }: { - first: FeeDescription[]; + list: FeeDescription[]; + sorting?: (a: string, b: string) => number; }): VNode { - return ( - <Fragment> - { - first.reduce( - (prev, info, idx) => { - const next = idx >= first.length - 1 ? undefined : first[idx + 1]; - - const nextIsMoreInfo = - next !== undefined && Amounts.cmp(next.value, info.value) === 0; - - prev.rows.push(info); - - if (nextIsMoreInfo) { - return prev; - } - - prev.rows = []; - prev.views.push(<FeeDescriptionRowsGroup infos={prev.rows} />); - return prev; - }, - { rows: [], views: [] } as { - rows: FeeDescription[]; - views: h.JSX.Element[]; - }, - ).views - } - </Fragment> - ); + const grouped = list.reduce((prev, cur) => { + if (!prev[cur.group]) { + prev[cur.group] = []; + } + prev[cur.group].push(cur); + return prev; + }, {} as Record<string, FeeDescription[]>); + const p = Object.keys(grouped) + .sort(sorting) + .map((i, idx) => <FeeDescriptionRowsGroup key={idx} infos={grouped[i]} />); + return <Fragment>{p}</Fragment>; } |