Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Currency selector #2739

Merged
merged 10 commits into from
Nov 28, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useEffect, useState } from 'react';
import { type HexString } from '@/shared/core';
import { useI18n } from '@/shared/i18n';
import { ValidationErrors, cnTw } from '@/shared/lib/utils';
import { Button, CaptionText, Countdown, FootnoteText, Select, Shimmering, SmallTitleText } from '@/shared/ui';
import { type DropdownOption, type DropdownResult } from '@/shared/ui/types';
import { Button, CaptionText, Countdown, FootnoteText, Shimmering, SmallTitleText } from '@/shared/ui';
import { Select } from '@/shared/ui-kit';
import { CameraAccessErrors, CameraError, WhiteTextButtonStyle } from '../common/constants';
import { type ErrorObject, type Progress, QrError, type VideoInput } from '../common/types';

Expand Down Expand Up @@ -42,8 +42,8 @@ export const QrReaderWrapper = ({ className, onResult, countdown, validationErro
const [progress, setProgress] = useState<Progress>();
const [isSuccess, setIsSuccess] = useState<boolean>(false);

const [activeCamera, setActiveCamera] = useState<DropdownResult<string>>();
const [availableCameras, setAvailableCameras] = useState<DropdownOption<string>[]>([]);
const [activeCamera, setActiveCamera] = useState<string>();
const [availableCameras, setAvailableCameras] = useState<Record<'title' | 'value', string>[]>([]);

useEffect(() => {
if (validationError) {
Expand All @@ -54,18 +54,16 @@ export const QrReaderWrapper = ({ className, onResult, countdown, validationErro
const isCameraOn = !(error && CameraAccessErrors.includes(error));

const onCameraList = (cameras: VideoInput[]) => {
const formattedCameras = cameras.map((camera, index) => ({
//eslint-disable-next-line i18next/no-literal-string
element: `${index + 1}. ${camera.label}`,
const formattedCameras = cameras.map((camera) => ({
title: camera.label,
value: camera.id,
id: camera.id,
}));

setAvailableCameras(formattedCameras);

if (formattedCameras.length > 1) {
// if multiple cameras are available we set first one as active
setActiveCamera(formattedCameras[0]);
const defaultCamera = formattedCameras.at(0);
if (defaultCamera) {
setActiveCamera(defaultCamera.value);
setIsLoading(false);
}
};
Expand Down Expand Up @@ -113,7 +111,7 @@ export const QrReaderWrapper = ({ className, onResult, countdown, validationErro
error === CameraError.INVALID_ERROR && 'blur-[13px]',
className,
),
cameraId: activeCamera?.value,
cameraId: activeCamera,
onStart: () => setIsLoading(false),
onCameraList,
onError,
Expand Down Expand Up @@ -160,16 +158,20 @@ export const QrReaderWrapper = ({ className, onResult, countdown, validationErro
)}
</div>

<div className="mb-4 h-8.5">
{availableCameras && availableCameras.length > 1 && (
<div className="mb-4 w-[208px]">
{availableCameras.length > 1 && (
<Select
theme="dark"
placeholder={t('onboarding.paritySigner.selectCameraLabel')}
selectedId={activeCamera?.id}
options={availableCameras}
className="w-[208px]"
value={activeCamera ?? null}
onChange={setActiveCamera}
/>
>
{availableCameras.map((camera, index) => (
<Select.Item key={camera.value} value={camera.value}>
{`${index + 1}. ${camera.title}`}
</Select.Item>
))}
</Select>
)}
</div>

Expand Down
70 changes: 33 additions & 37 deletions src/renderer/features/currency/CurrencyForm/ui/CurrencyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,18 @@ import { type FormEvent, useEffect } from 'react';

import { type CurrencyItem } from '@/shared/api/price-provider';
import { useI18n } from '@/shared/i18n';
import { Button, FootnoteText, HelpText, Select, Switch } from '@/shared/ui';
import { type DropdownOption } from '@/shared/ui/types';
import { nonNullable } from '@/shared/lib/utils';
import { Button, FootnoteText, HelpText, Switch } from '@/shared/ui';
import { Select } from '@/shared/ui-kit';
import { type Callbacks, currencyFormModel } from '../model/currency-form';

const getCurrencyOption = (currency: CurrencyItem): DropdownOption<CurrencyItem> => ({
id: currency.id.toString(),
value: currency,
element: [currency.code, currency.symbol, currency.name].filter(Boolean).join(' • '),
});
const getCurrencyTitle = (currency: CurrencyItem): string => {
return [currency.code, currency.symbol, currency.name].filter(nonNullable).join(' • ');
};

type Props = Callbacks;
export const CurrencyForm = ({ onSubmit }: Props) => {
const { t } = useI18n();
const isFormValid = useUnit(currencyFormModel.$isFormValid);

useEffect(() => {
currencyFormModel.events.callbacksChanged({ onSubmit });
Expand All @@ -32,34 +30,11 @@ export const CurrencyForm = ({ onSubmit }: Props) => {
fields: { fiatFlag, currency },
} = useForm(currencyFormModel.$currencyForm);

const isFormValid = useUnit(currencyFormModel.$isFormValid);
const cryptoCurrencies = useUnit(currencyFormModel.$cryptoCurrencies);
const popularFiatCurrencies = useUnit(currencyFormModel.$popularFiatCurrencies);
const unpopularFiatCurrencies = useUnit(currencyFormModel.$unpopularFiatCurrencies);

const currenciesOptions: DropdownOption<CurrencyItem>[] = [
{
id: 'crypto',
element: <HelpText className="text-text-secondary">{t('settings.currency.cryptocurrenciesLabel')}</HelpText>,
value: {} as CurrencyItem,
disabled: true,
},
...cryptoCurrencies.map(getCurrencyOption),
{
id: 'popular',
element: <HelpText className="text-text-secondary">{t('settings.currency.popularFiatLabel')}</HelpText>,
value: {} as CurrencyItem,
disabled: true,
},
...popularFiatCurrencies.map(getCurrencyOption),
{
id: 'unpopular',
element: <HelpText className="text-text-secondary">{t('settings.currency.unpopularFiatLabel')}</HelpText>,
value: {} as CurrencyItem,
disabled: true,
},
...unpopularFiatCurrencies.map(getCurrencyOption),
];

const submitForm = (event: FormEvent) => {
event.preventDefault();
submit();
Expand All @@ -76,11 +51,32 @@ export const CurrencyForm = ({ onSubmit }: Props) => {

<Select
placeholder={t('settings.currency.selectPlaceholder')}
disabled={!fiatFlag?.value}
options={currenciesOptions}
selectedId={currency?.value.toString()}
onChange={({ value }) => currency?.onChange(value.id)}
/>
disabled={!fiatFlag.value}
value={currency.value.toString()}
onChange={(value) => currency.onChange(Number(value))}
>
<Select.Group title={t('settings.currency.cryptocurrenciesLabel')}>
{cryptoCurrencies.map((currency) => (
<Select.Item key={currency.id} value={currency.id.toString()}>
{getCurrencyTitle(currency)}
</Select.Item>
))}
</Select.Group>
<Select.Group title={t('settings.currency.popularFiatLabel')}>
{popularFiatCurrencies.map((currency) => (
<Select.Item key={currency.id} value={currency.id.toString()}>
{getCurrencyTitle(currency)}
</Select.Item>
))}
</Select.Group>
<Select.Group title={t('settings.currency.unpopularFiatLabel')}>
{unpopularFiatCurrencies.map((currency) => (
<Select.Item key={currency.id} value={currency.id.toString()}>
{getCurrencyTitle(currency)}
</Select.Item>
))}
</Select.Group>
</Select>

<Button className="ml-auto w-fit" type="submit" disabled={!isFormValid}>
{t('settings.currency.save')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { useState } from 'react';
import { CryptoType } from '@/shared/core';
import { useI18n } from '@/shared/i18n';
import { cnTw } from '@/shared/lib/utils';
import { Button, CaptionText, FootnoteText, Icon, Loader, Select, SmallTitleText } from '@/shared/ui';
import { type DropdownOption, type DropdownResult } from '@/shared/ui/types';
import { Button, CaptionText, FootnoteText, Icon, Loader, SmallTitleText } from '@/shared/ui';
import { Select } from '@/shared/ui-kit';
import {
type DdAddressInfoDecoded,
type DdSeedInfo,
Expand Down Expand Up @@ -42,8 +42,9 @@ export const DdKeyQrReader = ({ size = 300, className, onGoBack, onResult }: Pro
const { t } = useI18n();

const [cameraState, setCameraState] = useState<CameraState>(CameraState.LOADING);
const [activeCamera, setActiveCamera] = useState<DropdownResult<string>>();
const [availableCameras, setAvailableCameras] = useState<DropdownOption<string>[]>([]);

const [activeCamera, setActiveCamera] = useState<string>();
const [availableCameras, setAvailableCameras] = useState<Record<'title' | 'value', string>[]>([]);

const [isScanComplete, setIsScanComplete] = useState(false);
const [{ decoded, total }, setProgress] = useState({ decoded: 0, total: 0 });
Expand All @@ -59,17 +60,16 @@ export const DdKeyQrReader = ({ size = 300, className, onGoBack, onResult }: Pro
].includes(cameraState);

const onCameraList = (cameras: VideoInput[]) => {
const formattedCameras = cameras.map((camera, index) => ({
//eslint-disable-next-line i18next/no-literal-string
element: `${index + 1}. ${camera.label}`,
const formattedCameras = cameras.map((camera) => ({
title: camera.label,
value: camera.id,
id: camera.id,
}));

setAvailableCameras(formattedCameras);

if (formattedCameras.length > 0) {
setActiveCamera(formattedCameras[0]);
const defaultCamera = formattedCameras.at(0);
if (defaultCamera) {
setActiveCamera(defaultCamera.value);
setCameraState(CameraState.ACTIVE);
}
};
Expand Down Expand Up @@ -222,10 +222,10 @@ export const DdKeyQrReader = ({ size = 300, className, onGoBack, onResult }: Pro
{t('onboarding.vault.scanTitle')}
</SmallTitleText>
<QrReader
bgVideo
size={size}
cameraId={activeCamera?.value}
cameraId={activeCamera}
isDynamicDerivations
bgVideo
className="relative top-[-24px] -scale-x-[1.125] scale-y-[1.125]"
wrapperClassName="translate-y-[-84px]"
onStart={() => setCameraState(CameraState.ACTIVE)}
Expand All @@ -235,17 +235,23 @@ export const DdKeyQrReader = ({ size = 300, className, onGoBack, onResult }: Pro
onError={onError}
/>

<div className="absolute bottom-[138px] z-10 flex h-8.5 w-full justify-center">
{availableCameras && availableCameras.length > 1 && (
<Select
theme="dark"
placeholder={t('onboarding.paritySigner.selectCameraLabel')}
selectedId={activeCamera?.id}
options={availableCameras}
className="w-[208px]"
onChange={setActiveCamera}
/>
)}
<div className="absolute bottom-[138px] z-10 w-full">
<div className="mx-auto w-[208px]">
{availableCameras.length > 1 && (
<Select
theme="dark"
placeholder={t('onboarding.paritySigner.selectCameraLabel')}
value={activeCamera ?? null}
onChange={setActiveCamera}
>
{availableCameras.map((camera, index) => (
<Select.Item key={camera.value} value={camera.value}>
{`${index + 1}. ${camera.title}`}
</Select.Item>
))}
</Select>
)}
</div>
</div>

<div className="absolute inset-0 mt-[58px] flex h-full w-full justify-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ const KeyQrReader = ({ size = 300, className, onResult }: Props) => {

setAvailableCameras(formattedCameras);

if (formattedCameras.length > 0) {
setActiveCamera(formattedCameras[0].value);
const defaultCamera = formattedCameras.at(0);
if (defaultCamera) {
setActiveCamera(defaultCamera.value);
setCameraState(CameraState.ACTIVE);
}
};
Expand Down
24 changes: 21 additions & 3 deletions src/renderer/shared/ui-kit/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import { Select } from './Select';
const meta: Meta<typeof Select> = {
title: 'Design System/kit/Select',
component: Select,
parameters: {
layout: 'centered',
},
render: (params) => {
const [value, onChange] = useState('');

Expand Down Expand Up @@ -83,6 +80,27 @@ export const Disabled: Story = {
},
};

export const Groups: Story = {
render: (params) => {
const [value, onChange] = useState('');

return (
<Box width="200px">
<Select {...params} placeholder="Select a fruit" value={value} onChange={onChange}>
<Select.Group title="Group 1">
<Select.Item value="item_1">Apple</Select.Item>
<Select.Item value="item_2">Orange</Select.Item>
</Select.Group>
<Select.Group title="Group 2">
<Select.Item value="item_3">Watermelon</Select.Item>
<Select.Item value="item_4">Banana</Select.Item>
</Select.Group>
</Select>
</Box>
);
},
};

export const Dark: Story = {
decorators: [
(Story, { args }) => {
Expand Down
19 changes: 18 additions & 1 deletion src/renderer/shared/ui-kit/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as RadixSelect from '@radix-ui/react-select';
import { type PropsWithChildren, createContext, useContext, useMemo } from 'react';
import { Children, type PropsWithChildren, type ReactNode, createContext, useContext, useMemo } from 'react';

import { type XOR } from '@/shared/core';
import { cnTw } from '@/shared/lib/utils';
Expand Down Expand Up @@ -130,6 +130,22 @@ const Content = ({ children }: PropsWithChildren) => {
);
};

type GroupProps = {
title: ReactNode;
};
const Group = ({ title, children }: PropsWithChildren<GroupProps>) => {
if (Children.count(children) === 0) return null;

return (
<RadixSelect.Group className="mb-1 last:mb-0">
<RadixSelect.Label>
<div className="mb-1 px-3 py-1 text-help-text text-text-secondary">{title}</div>
</RadixSelect.Label>
{children}
</RadixSelect.Group>
);
};

type ItemProps = {
value: string;
};
Expand All @@ -156,5 +172,6 @@ const Item = ({ value, children }: PropsWithChildren<ItemProps>) => {
};

export const Select = Object.assign(Root, {
Group,
Item,
});
Loading