Custom theme config (#315)

This commit is contained in:
Ildar Galeev 2024-07-12 18:47:33 +07:00 committed by GitHub
parent 8a8923d417
commit daa3fcdf73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 251 additions and 151 deletions

View File

@ -15,7 +15,7 @@
/> />
<title>Checkout</title> <title>Checkout</title>
</head> </head>
<body style="background: #163735"> <body>
<div id="global-spinner" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%)"> <div id="global-spinner" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%)">
<div style="display: flex; height: 128px"> <div style="display: flex; height: 128px">
<svg <svg
@ -26,8 +26,8 @@
> >
<defs> <defs>
<linearGradient id="loaderGradient" x1="100%" x2="0%" y1="0%" y2="100%"> <linearGradient id="loaderGradient" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#F6E05E" /> <stop offset="0%" stop-color="#000000" />
<stop offset="100%" stop-color="#ED8936" /> <stop offset="100%" stop-color="#EEEEEE" />
</linearGradient> </linearGradient>
</defs> </defs>
<g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"> <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">

View File

@ -1,5 +1,5 @@
import { ChakraBaseProvider, extendBaseTheme, theme as chakraTheme } from '@chakra-ui/react'; import { ChakraBaseProvider, extendBaseTheme, theme as chakraTheme } from '@chakra-ui/react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { ErrorAlert } from 'checkout/components'; import { ErrorAlert } from 'checkout/components';
import { CompletePaymentContext } from 'checkout/contexts'; import { CompletePaymentContext } from 'checkout/contexts';
@ -11,12 +11,7 @@ import { useInitialize } from './useInitialize';
const { Button, Spinner, Divider, Heading, Alert, Menu, Drawer } = chakraTheme.components; const { Button, Spinner, Divider, Heading, Alert, Menu, Drawer } = chakraTheme.components;
const theme = extendBaseTheme({ const common = {
fonts: {
body: 'Roboto, sans-serif',
heading: 'Roboto, sans-serif',
mono: 'monospace',
},
components: { components: {
Button, Button,
Spinner, Spinner,
@ -26,12 +21,62 @@ const theme = extendBaseTheme({
Menu, Menu,
Drawer, Drawer,
}, },
}); };
const defaultTheme = {
fonts: {
body: 'Roboto, sans-serif',
heading: 'Roboto, sans-serif',
mono: 'monospace',
},
colors: {
brand: {
50: '#E6FFFA',
100: '#B2F5EA',
200: '#81E6D9',
300: '#4FD1C5',
400: '#38B2AC',
500: '#319795',
600: '#2C7A7B',
700: '#285E61',
800: '#234E52',
900: '#1D4044',
},
},
semanticTokens: {
colors: {
mainContainerBg: {
default: 'gray.100',
},
viewContainerBg: {
default: 'white',
},
viewContainerLoaderBg: {
default: 'whiteAlpha.800',
},
bodyText: {
default: 'gray.800',
},
},
},
styles: {
global: {
body: {
bg: '#163735',
},
},
},
qrCode: {
back: '#FFFFFF',
fill: '#1A202C',
},
};
const ON_COMPLETE_TIMEOUT_MS = 1000 * 5; const ON_COMPLETE_TIMEOUT_MS = 1000 * 5;
export function App() { export function App() {
const { state, init } = useInitialize(); const { state, init } = useInitialize();
const [theme, setTheme] = useState(extendBaseTheme({ ...common, ...defaultTheme }));
useEffect(() => { useEffect(() => {
init(); init();
@ -44,25 +89,42 @@ export function App() {
} }
}, [state.status]); }, [state.status]);
useEffect(() => {
if (state.status === 'SUCCESS') {
const themes = state.data[1].appConfig?.themes;
if (isNil(themes) || themes.length === 0) return;
const initConfigThemeName = state.data[1].initConfig?.theme;
if (isNil(initConfigThemeName)) return;
const customTheme = themes.find((t) => t.themeName === initConfigThemeName);
if (isNil(customTheme)) return;
setTheme(extendBaseTheme({ ...common, ...customTheme }));
}
}, [state.status]);
return ( return (
<ChakraBaseProvider theme={theme}> <>
{state.status === 'SUCCESS' && ( {state.status === 'SUCCESS' && (
<CompletePaymentContext.Provider <ChakraBaseProvider theme={theme}>
value={{ <CompletePaymentContext.Provider
onComplete: () => value={{
setTimeout(() => { onComplete: () =>
const [transport, params] = state.data; setTimeout(() => {
transport.emit(CommunicatorEvents.finished); const [transport, params] = state.data;
transport.destroy(); transport.emit(CommunicatorEvents.finished);
const redirectUrl = params.initConfig?.redirectUrl; transport.destroy();
if (!isNil(redirectUrl)) { const redirectUrl = params.initConfig?.redirectUrl;
window.open(redirectUrl, '_self'); if (!isNil(redirectUrl)) {
} window.open(redirectUrl, '_self');
}, ON_COMPLETE_TIMEOUT_MS), }
}} }, ON_COMPLETE_TIMEOUT_MS),
> }}
<AppLayout initParams={state.data[1]} /> >
</CompletePaymentContext.Provider> <AppLayout initParams={state.data[1]} styledComponentsTheme={theme?.__styledComponents} />
</CompletePaymentContext.Provider>
</ChakraBaseProvider>
)} )}
{state.status === 'FAILURE' && ( {state.status === 'FAILURE' && (
<ErrorAlert <ErrorAlert
@ -71,6 +133,6 @@ export function App() {
title="Initialization failure" title="Initialization failure"
/> />
)} )}
</ChakraBaseProvider> </>
); );
} }

View File

@ -24,7 +24,7 @@ export function ErrorAlert({ title, description, isReloading }: ErrorAlertProps)
</AlertTitle> </AlertTitle>
<AlertDescription maxWidth="lg">{description}</AlertDescription> <AlertDescription maxWidth="lg">{description}</AlertDescription>
{isReloading && ( {isReloading && (
<Button colorScheme="teal" onClick={() => location.reload()}> <Button colorScheme="gray" onClick={() => location.reload()}>
Reload Reload
</Button> </Button>
)} )}

View File

@ -15,8 +15,8 @@ export function GlobalSpinner({ l }: GlobalSpinnerProps) {
<VStack align="center" minHeight={32} spacing={4}> <VStack align="center" minHeight={32} spacing={4}>
<Spinner <Spinner
alignItems="center" alignItems="center"
color="yellow.300" color="brand.500"
emptyColor="orange.400" emptyColor="brand.200"
size="xl" size="xl"
speed="0.65s" speed="0.65s"
thickness="4px" thickness="4px"

View File

@ -29,6 +29,7 @@ it('should return resolved init config', () => {
phoneNumber: null, phoneNumber: null,
redirectUrl: null, redirectUrl: null,
skipUserInteraction: false, skipUserInteraction: false,
theme: null,
}; };
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });

View File

@ -30,6 +30,7 @@ export const resolveInitConfig = (userConfig: Partial<InitConfig>): InitConfig =
terminalFormValues, terminalFormValues,
skipUserInteraction, skipUserInteraction,
isExternalIDIncluded, isExternalIDIncluded,
theme,
} = userConfig; } = userConfig;
return { return {
...resolvedIntegrationType, ...resolvedIntegrationType,
@ -46,5 +47,6 @@ export const resolveInitConfig = (userConfig: Partial<InitConfig>): InitConfig =
terminalFormValues: setDefault(resolveObject(terminalFormValues), undefined), terminalFormValues: setDefault(resolveObject(terminalFormValues), undefined),
skipUserInteraction: setDefault(resolveBoolean(skipUserInteraction), false), skipUserInteraction: setDefault(resolveBoolean(skipUserInteraction), false),
isExternalIDIncluded: setDefault(resolveBoolean(isExternalIDIncluded), true), isExternalIDIncluded: setDefault(resolveBoolean(isExternalIDIncluded), true),
theme: resolveString(theme),
}; };
}; };

View File

@ -20,6 +20,8 @@ export type InitConfig = {
isExternalIDIncluded?: boolean; isExternalIDIncluded?: boolean;
}; };
export type ThemeConfig = Record<string, any>;
export type AppConfig = { export type AppConfig = {
capiEndpoint?: string; capiEndpoint?: string;
wrapperEndpoint?: string; wrapperEndpoint?: string;
@ -28,6 +30,7 @@ export type AppConfig = {
brandName?: string; brandName?: string;
urlShortenerEndpoint?: string; urlShortenerEndpoint?: string;
sentryDsn?: string; sentryDsn?: string;
themes: ThemeConfig[];
}; };
export type InitParams = { export type InitParams = {

View File

@ -40,7 +40,9 @@ const theme: Theme = {
border: palette.Loblolly, border: palette.Loblolly,
}, },
input: { input: {
backgroundColor: 'none',
border: palette.Loblolly, border: palette.Loblolly,
color: '#000000',
placeholder: palette.RegentGray, placeholder: palette.RegentGray,
error: palette.Cinnabar, error: palette.Cinnabar,
focus: palette.Zeus, focus: palette.Zeus,

View File

@ -40,7 +40,9 @@ const theme: Theme = {
border: palette.SilverChalice, border: palette.SilverChalice,
}, },
input: { input: {
backgroundColor: 'none',
border: palette.SilverChalice, border: palette.SilverChalice,
color: '#000000',
placeholder: palette.RegentGray, placeholder: palette.RegentGray,
error: palette.Cinnabar, error: palette.Cinnabar,
focus: palette.CodGray, focus: palette.CodGray,

View File

@ -20,7 +20,9 @@ export type Theme = {
border: string; border: string;
}; };
input: { input: {
backgroundColor: string;
border: string; border: string;
color: string;
placeholder: string; placeholder: string;
error: string; error: string;
focus: string; focus: string;

View File

@ -15,6 +15,7 @@ import { toCustomizationContext } from './utils';
type AppLayoutProps = { type AppLayoutProps = {
initParams: InitParams; initParams: InitParams;
styledComponentsTheme?: Record<string, any>;
}; };
const GlobalContainer = lazy(() => import('../GlobalContainer/GlobalContainer')); const GlobalContainer = lazy(() => import('../GlobalContainer/GlobalContainer'));
@ -35,8 +36,8 @@ const ModalContainer = ({ children }: { children: React.ReactNode }) => (
</Flex> </Flex>
); );
export function AppLayout({ initParams }: AppLayoutProps) { export function AppLayout({ initParams, styledComponentsTheme }: AppLayoutProps) {
const theme = getTheme(initParams.appConfig.fixedTheme); const theme = styledComponentsTheme || getTheme(initParams.appConfig.fixedTheme);
const { modelsState, init } = useInitModels(); const { modelsState, init } = useInitModels();
const customizationContextValue = toCustomizationContext(initParams.initConfig); const customizationContextValue = toCustomizationContext(initParams.initConfig);
const initLocaleCode = customizationContextValue.initLocaleCode; const initLocaleCode = customizationContextValue.initLocaleCode;

View File

@ -38,7 +38,9 @@ export function DestinationInfo({ destination }: DestinationInfoProps) {
{isAmountRandomized && ( {isAmountRandomized && (
<Alert borderRadius="xl" p={3} status="warning"> <Alert borderRadius="xl" p={3} status="warning">
<AlertIcon /> <AlertIcon />
<Text fontSize="sm">{l['form.p2p.destination.randomizeAmountDescription']}</Text> <Text color="bodyText" fontSize="sm">
{l['form.p2p.destination.randomizeAmountDescription']}
</Text>
</Alert> </Alert>
)} )}
<InfoItem isDivider={false} label={l['form.p2p.destination.amount']} value={viewAmount} /> <InfoItem isDivider={false} label={l['form.p2p.destination.amount']} value={viewAmount} />

View File

@ -35,26 +35,28 @@ export function Destinations({ destinations }: DestinationsProps) {
return ( return (
<VStack align="stretch" minH="md" spacing={5}> <VStack align="stretch" minH="md" spacing={5}>
<Heading as="h5" size="sm" textAlign="center"> <Heading as="h5" color="bodyText" size="sm" textAlign="center">
{l['form.p2p.destinations.heading']} {l['form.p2p.destinations.heading']}
</Heading> </Heading>
<Divider /> <Divider />
<P2PAlert /> <P2PAlert />
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
<Text fontWeight="medium">{l['form.p2p.destination.info']}</Text> <Text color="bodyText" fontWeight="medium">
{l['form.p2p.destination.info']}
</Text>
{destinations.map((destination, index) => ( {destinations.map((destination, index) => (
<DestinationInfo key={index} destination={destination} /> <DestinationInfo key={index} destination={destination} />
))} ))}
</VStack> </VStack>
<Spacer /> <Spacer />
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
<Text fontSize="sm" textAlign="center"> <Text color="bodyText" fontSize="sm" textAlign="center">
{l['form.p2p.complete.info']} {l['form.p2p.complete.info']}
</Text> </Text>
<VStack align="stretch" spacing={5}> <VStack align="stretch" spacing={5}>
<Button <Button
borderRadius="lg" borderRadius="lg"
colorScheme="teal" colorScheme="brand"
isLoading={status === 'LOADING' || status === 'SUCCESS'} isLoading={status === 'LOADING' || status === 'SUCCESS'}
loadingText={l['form.p2p.complete.loading']} loadingText={l['form.p2p.complete.loading']}
size="lg" size="lg"
@ -64,7 +66,7 @@ export function Destinations({ destinations }: DestinationsProps) {
</Button> </Button>
{status === 'SUCCESS' && initContext?.redirectUrl && ( {status === 'SUCCESS' && initContext?.redirectUrl && (
<Button <Button
colorScheme="teal" colorScheme="brand"
size="lg" size="lg"
variant="link" variant="link"
onClick={() => window.open(initContext.redirectUrl, '_self')} onClick={() => window.open(initContext.redirectUrl, '_self')}

View File

@ -62,11 +62,11 @@ export function InfoItem({ label, value, isCopyable, formatter, icon, isDivider
return ( return (
<VStack align="stretch"> <VStack align="stretch">
<Flex> <Flex>
<Text>{label}</Text> <Text color="bodyText">{label}</Text>
<Spacer /> <Spacer />
<Flex alignItems="center" gap={2}> <Flex alignItems="center" gap={2}>
{icon && <IconContainer />} {icon && <IconContainer />}
<Text fontWeight="medium" textAlign="end"> <Text color="bodyText" fontWeight="medium" textAlign="end">
{displayValue} {displayValue}
</Text> </Text>
{isCopyable && <IconButton aria-label="Copy" icon={<CopyIcon />} size="xs" onClick={onCopy} />} {isCopyable && <IconButton aria-label="Copy" icon={<CopyIcon />} size="xs" onClick={onCopy} />}

View File

@ -18,7 +18,7 @@ export function FetchRequisitesError() {
<Spacer /> <Spacer />
{initContext?.redirectUrl && ( {initContext?.redirectUrl && (
<Button <Button
colorScheme="teal" colorScheme="brand"
size="lg" size="lg"
variant="link" variant="link"
onClick={() => window.open(initContext.redirectUrl, '_self')} onClick={() => window.open(initContext.redirectUrl, '_self')}

View File

@ -26,7 +26,7 @@ export function GatewaySelector({ gateways, onSelect }: GatewaySelectorProps) {
<Spacer /> <Spacer />
<Button <Button
borderRadius="lg" borderRadius="lg"
colorScheme="teal" colorScheme="brand"
isDisabled={isNil(gateway)} isDisabled={isNil(gateway)}
size="lg" size="lg"
onClick={() => onSelect(gateway.id)} onClick={() => onSelect(gateway.id)}

View File

@ -29,7 +29,7 @@ export function IconPane({ label, icon, isActive, onClick }: IconPaneProps) {
onClick={onClick} onClick={onClick}
> >
<IconContainer /> <IconContainer />
<Text mb={4} ml={2} mr={2} userSelect="none"> <Text color="bodyText" mb={4} ml={2} mr={2} userSelect="none">
{label} {label}
</Text> </Text>
</Flex> </Flex>

View File

@ -11,9 +11,9 @@ export function RequisitesLoader() {
return ( return (
<VStack alignItems="center" justifyContent="center" minH="md"> <VStack alignItems="center" justifyContent="center" minH="md">
<VStack align="center" minHeight={32} spacing={4}> <VStack align="center" minHeight={32} spacing={4}>
<Spinner color="teal.500" emptyColor="gray.200" size="xl" speed="0.65s" thickness="4px" /> <Spinner color="brand.500" emptyColor="brand.200" size="xl" speed="0.65s" thickness="4px" />
{message !== '' && ( {message !== '' && (
<Text fontSize="md" fontWeight="medium" textAlign="center"> <Text color="bodyText" fontSize="md" fontWeight="medium" textAlign="center">
{message} {message}
</Text> </Text>
)} )}

View File

@ -23,15 +23,19 @@ export function DetailsDrawer({ isOpen, onClose, name, description }: DetailsDra
return ( return (
<Drawer isOpen={isOpen} placement="top" size="sm" onClose={onClose}> <Drawer isOpen={isOpen} placement="top" size="sm" onClose={onClose}>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent> <DrawerContent background="viewContainerBg">
<DrawerBody> <DrawerBody>
<VStack align="stretch"> <VStack align="stretch">
{name && ( {name && (
<Text fontSize="xl" fontWeight="medium"> <Text color="bodyText" fontSize="xl" fontWeight="medium">
{truncate(name, 100)} {truncate(name, 100)}
</Text> </Text>
)} )}
{description && <Text fontSize="lg">{truncate(description, 140)}</Text>} {description && (
<Text color="bodyText" fontSize="lg">
{truncate(description, 140)}
</Text>
)}
</VStack> </VStack>
</DrawerBody> </DrawerBody>
<DrawerFooter> <DrawerFooter>

View File

@ -34,22 +34,26 @@ export function InfoContainer({ viewAmount }: InfoProps) {
)} )}
<Flex alignItems="center" justifyContent="space-between"> <Flex alignItems="center" justifyContent="space-between">
<Text fontSize="3xl" fontWeight="medium"> <Text color="bodyText" fontSize="3xl" fontWeight="medium">
{viewAmount} {viewAmount}
</Text> </Text>
{!isLargerThan768 && (name || description) && ( {!isLargerThan768 && (name || description) && (
<Button colorScheme="gray" rightIcon={<ChevronDownIcon />} variant="ghost" onClick={onOpen}> <Button colorScheme="gray" rightIcon={<ChevronDownIcon />} onClick={onOpen}>
{l['info.details']} {l['info.details']}
</Button> </Button>
)} )}
</Flex> </Flex>
{isLargerThan768 && name && ( {isLargerThan768 && name && (
<Text fontSize="xl" fontWeight="medium"> <Text color="bodyText" fontSize="xl" fontWeight="medium">
{truncate(name, 80)} {truncate(name, 80)}
</Text> </Text>
)} )}
{isLargerThan768 && description && <Text fontSize="lg">{truncate(description, 120)}</Text>} {isLargerThan768 && description && (
<Text color="bodyText" fontSize="lg">
{truncate(description, 120)}
</Text>
)}
</VStack> </VStack>
<DetailsDrawer description={description} isOpen={isOpen} name={name} onClose={onClose} /> <DetailsDrawer description={description} isOpen={isOpen} name={name} onClose={onClose} />
</> </>

View File

@ -11,7 +11,7 @@ export const Loader = () => (
<motion.div animate="show" exit="exit" initial="hidden" variants={fadeIn}> <motion.div animate="show" exit="exit" initial="hidden" variants={fadeIn}>
<Flex <Flex
alignItems="center" alignItems="center"
background="whiteAlpha.800" background="viewContainerLoaderBg"
borderRadius="xl" borderRadius="xl"
height="100%" height="100%"
justifyContent="center" justifyContent="center"
@ -20,7 +20,7 @@ export const Loader = () => (
top={0} top={0}
width="100%" width="100%"
> >
<Spinner color="teal.500" emptyColor="gray.200" size="xl" speed="0.65s" thickness="4px" /> <Spinner color="brand.500" emptyColor="brand.200" size="xl" speed="0.65s" thickness="4px" />
</Flex> </Flex>
</motion.div> </motion.div>
); );

View File

@ -76,10 +76,11 @@ export function LocaleSelector({ initLocaleCode, onLocaleChange }: LocaleSelecto
<ChevronDownIcon /> <ChevronDownIcon />
</Flex> </Flex>
</MenuButton> </MenuButton>
<MenuList> <MenuList backgroundColor="viewContainerBg">
{Object.entries(localeInfo).map(([code, { flag, long }]) => ( {Object.entries(localeInfo).map(([code, { flag, long }]) => (
<MenuItem <MenuItem
key={code} key={code}
backgroundColor="viewContainerBg"
onClick={() => { onClick={() => {
setActiveLocaleCode(code); setActiveLocaleCode(code);
onLocaleChange(code); onLocaleChange(code);
@ -91,7 +92,9 @@ export function LocaleSelector({ initLocaleCode, onLocaleChange }: LocaleSelecto
{flag} {flag}
</Text> </Text>
)} )}
<Text fontSize="md">{long}</Text> <Text color="bodyText" fontSize="md">
{long}
</Text>
</Flex> </Flex>
</MenuItem> </MenuItem>
))} ))}

View File

@ -20,7 +20,9 @@ export function NoAvailablePaymentMethodsView() {
return ( return (
<Container> <Container>
<Text centered={true}>{l['info.modal.no.available.payment.method']}</Text> <Text centered={true} color="bodyText">
{l['info.modal.no.available.payment.method']}
</Text>
</Container> </Container>
); );
} }

View File

@ -1,20 +1,22 @@
import { Spacer, VStack, Text, Flex, HStack, Button } from '@chakra-ui/react';
import { useContext } from 'react'; import { useContext } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import { BackwardBox } from 'checkout/components';
import {
CustomizationContext,
LocaleContext,
PaymentContext,
PaymentModelContext,
ViewModelContext,
} from 'checkout/contexts';
import { CardHolder } from './CardHolder'; import { CardHolder } from './CardHolder';
import { CardNumber } from './CardNumber'; import { CardNumber } from './CardNumber';
import { ExpireDate } from './ExpireDate'; import { ExpireDate } from './ExpireDate';
import { SecureCode } from './SecureCode'; import { SecureCode } from './SecureCode';
import { CardFormInputs } from './types'; import { CardFormInputs } from './types';
import { isSecureCodeAvailable } from './utils'; import { isSecureCodeAvailable } from './utils';
import {
CustomizationContext,
LocaleContext,
PaymentContext,
PaymentModelContext,
ViewModelContext,
} from '../../../../common/contexts';
import { ChevronButton, FormGroup, HeaderWrapper, PayButton, Title } from '../../../../components/legacy';
export function CardForm() { export function CardForm() {
const { l } = useContext(LocaleContext); const { l } = useContext(LocaleContext);
@ -48,22 +50,22 @@ export function CardForm() {
const isSecureCode = isSecureCodeAvailable(watch('cardNumber')); const isSecureCode = isSecureCodeAvailable(watch('cardNumber'));
return ( return (
<> <form onSubmit={handleSubmit(onSubmit)}>
<HeaderWrapper> <VStack align="stretch" spacing={5}>
{hasBackward && <ChevronButton type="left" onClick={backward} />} <Flex alignItems="center" direction="row">
<Title>{l['form.header.pay.card.label']}</Title> {hasBackward && <BackwardBox onClick={backward} />}
</HeaderWrapper> <Text color="bodyText" fontWeight="medium" textAlign="center" width="full">
<form onSubmit={handleSubmit(onSubmit)}> {l['form.header.pay.card.label']}
<FormGroup> </Text>
<CardNumber </Flex>
fieldError={errors.cardNumber} <CardNumber
isDirty={dirtyFields.cardNumber} fieldError={errors.cardNumber}
locale={l} isDirty={dirtyFields.cardNumber}
register={register} locale={l}
watch={watch} register={register}
/> watch={watch}
</FormGroup> />
<FormGroup $gap={10}> <HStack align="stretch" spacing={5}>
<ExpireDate <ExpireDate
fieldError={errors.expireDate} fieldError={errors.expireDate}
isDirty={dirtyFields.expireDate} isDirty={dirtyFields.expireDate}
@ -80,19 +82,20 @@ export function CardForm() {
register={register} register={register}
/> />
)} )}
</FormGroup> </HStack>
{requireCardHolder && ( {requireCardHolder && (
<FormGroup> <CardHolder
<CardHolder fieldError={errors.cardHolder}
fieldError={errors.cardHolder} isDirty={dirtyFields.cardHolder}
isDirty={dirtyFields.cardHolder} locale={l}
locale={l} register={register}
register={register} />
/>
</FormGroup>
)} )}
<PayButton l={l} viewAmount={viewAmount} /> <Spacer />
</form> <Button borderRadius="lg" colorScheme="brand" size="lg" type="submit" variant="solid">
</> {l['form.button.pay.label']} {viewAmount}
</Button>
</VStack>
</form>
); );
} }

View File

@ -5,7 +5,6 @@ import { validateCardHolder } from './validateCardHolder';
import { Locale } from '../../../../../common/contexts'; import { Locale } from '../../../../../common/contexts';
import { isNil } from '../../../../../common/utils'; import { isNil } from '../../../../../common/utils';
import { Input } from '../../../../legacy'; import { Input } from '../../../../legacy';
import { ReactComponent as UserIcon } from '../../../../legacy/icon/user.svg';
import { CardFormInputs } from '../types'; import { CardFormInputs } from '../types';
export type CardHolderProps = { export type CardHolderProps = {
@ -24,7 +23,7 @@ export const CardHolder = ({ register, locale, fieldError, isDirty }: CardHolder
autoComplete="cc-name" autoComplete="cc-name"
dirty={isDirty} dirty={isDirty}
error={!isNil(fieldError)} error={!isNil(fieldError)}
icon={<UserIcon />} // icon={<UserIcon />}
id="card-holder-input" id="card-holder-input"
mark={true} mark={true}
placeholder={locale['form.input.cardholder.placeholder']} placeholder={locale['form.input.cardholder.placeholder']}

View File

@ -5,8 +5,7 @@ import { formatCardNumber } from './formatCardNumber';
import { validateCardNumber } from './validateCardNumber'; import { validateCardNumber } from './validateCardNumber';
import { Locale } from '../../../../../common/contexts'; import { Locale } from '../../../../../common/contexts';
import { isNil } from '../../../../../common/utils'; import { isNil } from '../../../../../common/utils';
import { Input, CardTypeIcon } from '../../../../legacy'; import { Input } from '../../../../legacy';
import { ReactComponent as CardIcon } from '../../../../legacy/icon/card.svg';
import { CardFormInputs } from '../types'; import { CardFormInputs } from '../types';
const InputContainer = styled.div` const InputContainer = styled.div`
@ -28,7 +27,7 @@ export type CardNumberProps = {
isDirty: boolean; isDirty: boolean;
}; };
export const CardNumber = ({ register, locale, fieldError, isDirty, watch }: CardNumberProps) => ( export const CardNumber = ({ register, locale, fieldError, isDirty }: CardNumberProps) => (
<InputContainer> <InputContainer>
<CardNumberInput <CardNumberInput
{...register('cardNumber', { {...register('cardNumber', {
@ -38,13 +37,13 @@ export const CardNumber = ({ register, locale, fieldError, isDirty, watch }: Car
autoComplete="cc-number" autoComplete="cc-number"
dirty={isDirty} dirty={isDirty}
error={!isNil(fieldError)} error={!isNil(fieldError)}
icon={<CardIcon />} // icon={<CardIcon />}
id="card-number-input" id="card-number-input"
mark={true} mark={true}
placeholder={locale['form.input.card.placeholder']} placeholder={locale['form.input.card.placeholder']}
type="tel" type="tel"
onInput={formatCardNumber} onInput={formatCardNumber}
/> />
<CardTypeIcon cardNumber={watch('cardNumber')} /> {/* <CardTypeIcon cardNumber={watch('cardNumber')} /> */}
</InputContainer> </InputContainer>
); );

View File

@ -5,7 +5,6 @@ import { validateExpireDate } from './validateExpireDate';
import { Locale } from '../../../../../common/contexts'; import { Locale } from '../../../../../common/contexts';
import { isNil } from '../../../../../common/utils'; import { isNil } from '../../../../../common/utils';
import { Input } from '../../../../legacy'; import { Input } from '../../../../legacy';
import { ReactComponent as CalendarIcon } from '../../../../legacy/icon/calendar.svg';
import { CardFormInputs } from '../types'; import { CardFormInputs } from '../types';
export type ExpireDateProps = { export type ExpireDateProps = {
@ -24,7 +23,7 @@ export const ExpireDate = ({ register, locale, fieldError, isDirty }: ExpireDate
autoComplete="cc-exp" autoComplete="cc-exp"
dirty={isDirty} dirty={isDirty}
error={!isNil(fieldError)} error={!isNil(fieldError)}
icon={<CalendarIcon />} // icon={<CalendarIcon />}
id="expire-date-input" id="expire-date-input"
mark={true} mark={true}
placeholder={locale['form.input.expiry.placeholder']} placeholder={locale['form.input.expiry.placeholder']}

View File

@ -6,7 +6,6 @@ import { validateSecureCode } from './validateSecureCode';
import { Locale } from '../../../../../common/contexts'; import { Locale } from '../../../../../common/contexts';
import { isNil, safeVal } from '../../../../../common/utils'; import { isNil, safeVal } from '../../../../../common/utils';
import { Input } from '../../../../legacy'; import { Input } from '../../../../legacy';
import { ReactComponent as LockIcon } from '../../../../legacy/icon/lock.svg';
import { CardFormInputs } from '../types'; import { CardFormInputs } from '../types';
export interface SecureCodeProps { export interface SecureCodeProps {
@ -32,7 +31,7 @@ export const SecureCode = ({ cardNumber, locale, obscureCardCvv, register, field
autoComplete="cc-csc" autoComplete="cc-csc"
dirty={isDirty} dirty={isDirty}
error={!isNil(fieldError)} error={!isNil(fieldError)}
icon={<LockIcon />} // icon={<LockIcon />}
id="secure-code-input" id="secure-code-input"
mark={true} mark={true}
placeholder={getPlaceholder(cardNumber, locale)} placeholder={getPlaceholder(cardNumber, locale)}

View File

@ -17,7 +17,7 @@ export function PinikleAddon({ localePath, redirectLink }: PinikleAddonProps) {
<Text fontSize="md" fontWeight="medium"> <Text fontSize="md" fontWeight="medium">
{locale['label']} {locale['label']}
</Text> </Text>
<Button colorScheme="teal" variant="link" onClick={() => window.open(redirectLink, '_blank')}> <Button colorScheme="brand" variant="link" onClick={() => window.open(redirectLink, '_blank')}>
{locale['link']} {locale['link']}
</Button> </Button>
</VStack> </VStack>

View File

@ -96,7 +96,7 @@ export function MetadataForm({ provider }: MetadataFormProps) {
)} )}
{!isNil(addon) && <Addon addon={addon} />} {!isNil(addon) && <Addon addon={addon} />}
<Spacer /> <Spacer />
<Button borderRadius="lg" colorScheme="teal" size="lg" type="submit" variant="solid"> <Button borderRadius="lg" colorScheme="brand" size="lg" type="submit" variant="solid">
{l['form.button.pay.label']} {viewAmount} {l['form.button.pay.label']} {viewAmount}
</Button> </Button>
</VStack> </VStack>

View File

@ -43,7 +43,7 @@ export function PaymentProcessFailedView() {
{l['form.header.final.error.label']} {l['form.header.final.error.label']}
</Text> </Text>
{!isNil(exception) && ( {!isNil(exception) && (
<Text fontSize="lg" textAlign="center"> <Text color="bodyText" fontSize="lg" textAlign="center">
{getErrorDescription(exception)} {getErrorDescription(exception)}
</Text> </Text>
)} )}
@ -52,7 +52,7 @@ export function PaymentProcessFailedView() {
<VStack align="stretch" spacing={6}> <VStack align="stretch" spacing={6}>
<Button <Button
borderRadius="lg" borderRadius="lg"
colorScheme="teal" colorScheme="brand"
size="lg" size="lg"
variant="solid" variant="solid"
onClick={() => location.reload()} onClick={() => location.reload()}

View File

@ -59,7 +59,7 @@ export function PaymentResultView() {
{l[label]} {l[label]}
</Text> </Text>
{!isNil(description) && ( {!isNil(description) && (
<Text fontSize="lg" textAlign="center"> <Text color="bodyText" fontSize="lg" textAlign="center">
{l[description]} {l[description]}
</Text> </Text>
)} )}
@ -67,13 +67,13 @@ export function PaymentResultView() {
<Spacer /> <Spacer />
<VStack align="stretch" spacing={6}> <VStack align="stretch" spacing={6}>
{hasActions && isExternalIdEmpty(conditions) && ( {hasActions && isExternalIdEmpty(conditions) && (
<Button borderRadius="lg" colorScheme="teal" size="lg" variant="solid" onClick={retry}> <Button borderRadius="lg" colorScheme="brand" size="lg" variant="solid" onClick={retry}>
{l['form.button.pay.again.label']} {l['form.button.pay.again.label']}
</Button> </Button>
)} )}
{initContext?.redirectUrl && ( {initContext?.redirectUrl && (
<Button <Button
colorScheme="teal" colorScheme="brand"
size="lg" size="lg"
variant="link" variant="link"
onClick={() => window.open(initContext.redirectUrl, '_self')} onClick={() => window.open(initContext.redirectUrl, '_self')}

View File

@ -1,3 +1,4 @@
import { useTheme } from '@chakra-ui/react';
import kjua from 'kjua'; import kjua from 'kjua';
import styled from 'styled-components'; import styled from 'styled-components';
@ -11,12 +12,16 @@ export type QRCodeProps = {
}; };
export function QRCode({ text }: QRCodeProps) { export function QRCode({ text }: QRCodeProps) {
const {
qrCode: { back, fill },
} = useTheme();
return ( return (
<Wrapper <Wrapper
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: kjua({ __html: kjua({
size: 224, size: 224,
fill: '#2596A1', back,
fill,
rounded: 100, rounded: 100,
crisp: true, crisp: true,
ecLevel: 'H', ecLevel: 'H',

View File

@ -1,28 +1,15 @@
import { Divider, useClipboard, useToast, VStack, Text, Button } from '@chakra-ui/react';
import isMobile from 'ismobilejs'; import isMobile from 'ismobilejs';
import { useContext, useEffect, useRef } from 'react'; import { useContext, useEffect, useRef } from 'react';
import styled from 'styled-components';
import { LocaleContext, PaymentConditionsContext, PaymentContext, PaymentModelContext } from 'checkout/contexts';
import { PaymentInteractionRequested, PaymentStarted } from 'checkout/paymentCondition';
import { isNil } from 'checkout/utils';
import { findMetadata } from 'checkout/utils/findMetadata';
import { QRCode } from './QrCode'; import { QRCode } from './QrCode';
import { QrCodeFormMetadata } from '../../../common/backend/payments'; import { QrCodeFormMetadata } from '../../../common/backend/payments';
import { LocaleContext, PaymentConditionsContext, PaymentContext, PaymentModelContext } from '../../../common/contexts'; import { Input } from '../../../components/legacy';
import { PaymentInteractionRequested, PaymentStarted } from '../../../common/paymentCondition';
import { isNil } from '../../../common/utils';
import { findMetadata } from '../../../common/utils/findMetadata';
import { Button, CopyToClipboardButton, Hr, Input } from '../../../components/legacy';
const Instruction = styled.p`
font-weight: 500;
font-size: 16px;
line-height: 24px;
text-align: center;
margin: 0;
`;
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const isQrCodeRedirect = (formMetadata: QrCodeFormMetadata) => const isQrCodeRedirect = (formMetadata: QrCodeFormMetadata) =>
!isNil(formMetadata) && !isNil(formMetadata) &&
@ -48,13 +35,21 @@ export function QrCodeView() {
startWaitingPaymentResult(); startWaitingPaymentResult();
}, []); }, []);
const copyToClipboard = () => { const { onCopy, hasCopied } = useClipboard(interaction.qrCode);
qrCodeInputRef.current.select(); const toast = useToast();
document.execCommand('copy');
}; useEffect(() => {
if (!hasCopied) return;
toast({
title: l['form.button.copied.label'],
status: 'success',
variant: 'subtle',
duration: 3000,
});
}, [hasCopied, l]);
return ( return (
<Wrapper> <VStack align="stretch" spacing={5}>
{qrCodeForm && ( {qrCodeForm && (
<> <>
{qrCodeForm.isCopyCodeBlock && ( {qrCodeForm.isCopyCodeBlock && (
@ -65,17 +60,24 @@ export function QrCodeView() {
id="qr-code-input" id="qr-code-input"
readOnly={true} readOnly={true}
></Input> ></Input>
<CopyToClipboardButton l={l} onClick={() => copyToClipboard()} /> <Button borderRadius="lg" colorScheme="brand" size="lg" onClick={onCopy}>
<Hr /> {l['form.button.copy.label']}
</Button>
<Divider />
</> </>
)} )}
<Instruction>{l['form.qr.code']}</Instruction> <Text color="bodyText" fontWeight="medium" textAlign="center">
{l['form.qr.code']}
</Text>
<QRCode text={interaction.qrCode} /> <QRCode text={interaction.qrCode} />
{initContext.redirectUrl && ( {initContext.redirectUrl && (
<> <>
<Hr /> <Divider />
<Button <Button
id="back-to-website-btn" borderRadius="lg"
colorScheme="brand"
size="lg"
variant="link"
onClick={() => window.open(initContext.redirectUrl, '_self')} onClick={() => window.open(initContext.redirectUrl, '_self')}
> >
{l['form.button.back.to.website']} {l['form.button.back.to.website']}
@ -84,6 +86,6 @@ export function QrCodeView() {
)} )}
</> </>
)} )}
</Wrapper> </VStack>
); );
} }

View File

@ -32,7 +32,7 @@ export function ViewContainer() {
<> <>
<Flex <Flex
alignItems="stretch" alignItems="stretch"
background="gray.50" background="mainContainerBg"
borderRadius="2xl" borderRadius="2xl"
direction={['column', 'column', 'row']} direction={['column', 'column', 'row']}
gap={4} gap={4}
@ -41,9 +41,9 @@ export function ViewContainer() {
<InfoContainer viewAmount={viewAmount}></InfoContainer> <InfoContainer viewAmount={viewAmount}></InfoContainer>
<ViewModelContext.Provider value={{ viewModel, viewAmount, goTo, forward, backward }}> <ViewModelContext.Provider value={{ viewModel, viewAmount, goTo, forward, backward }}>
<Box <Box
background="white" background="viewContainerBg"
border="1px solid" // border="1px solid"
borderColor="gray.200" // borderColor="gray.200"
borderRadius="xl" borderRadius="xl"
p={[4, 4, 6]} p={[4, 4, 6]}
position="relative" position="relative"

View File

@ -23,6 +23,8 @@ const Icon = styled.div`
`; `;
const StyledInput = styled.input<{ $hasIcon?: boolean }>` const StyledInput = styled.input<{ $hasIcon?: boolean }>`
background-color: ${({ theme }) => theme.input.backgroundColor};
color: ${({ theme }) => theme.input.color};
margin: 0; margin: 0;
width: 100%; width: 100%;
height: 48px; height: 48px;
@ -35,11 +37,11 @@ const StyledInput = styled.input<{ $hasIcon?: boolean }>`
padding-left: ${({ $hasIcon }) => `${$hasIcon ? CONTENT_OFFSET + ICON_SIZE + TEXT_ICON_OFFSET : CONTENT_OFFSET}px`}; padding-left: ${({ $hasIcon }) => `${$hasIcon ? CONTENT_OFFSET + ICON_SIZE + TEXT_ICON_OFFSET : CONTENT_OFFSET}px`};
padding-right: ${CONTENT_OFFSET}px; padding-right: ${CONTENT_OFFSET}px;
appearance: none; appearance: none;
transition: border-color 0.3s; /* transition: border-color 0.3s; */
::placeholder { ::placeholder {
color: ${({ theme }) => theme.input.placeholder}; color: ${({ theme }) => theme.input.placeholder};
opacity: 1; /* opacity: 1; */
} }
&:focus-visible { &:focus-visible {