feat: add i18next and locales

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-05-05 16:05:22 +06:00
parent db5f1015ed
commit a43bbe719a
19 changed files with 240 additions and 54 deletions

120
package-lock.json generated
View File

@ -8,9 +8,11 @@
"name": "aurawebapp",
"version": "0.0.0",
"dependencies": {
"i18next": "^22.4.15",
"react": "^18.2.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.2.2",
"react-router-dom": "^6.11.0"
},
"devDependencies": {
@ -339,6 +341,17 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz",
"integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==",
"dependencies": {
"regenerator-runtime": "^0.13.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
@ -2066,6 +2079,36 @@
"node": ">=4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "22.4.15",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.15.tgz",
"integrity": "sha512-yYudtbFrrmWKLEhl6jvKUYyYunj4bTBCe2qIUYAxbXoPusY7YmdwPvOE6fx6UIfWvmlbCWDItr7wIs8KEBZ5Zg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.20.6"
}
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -2573,6 +2616,27 @@
"react": "^18.2.0"
}
},
"node_modules/react-i18next": {
"version": "12.2.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-12.2.2.tgz",
"integrity": "sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==",
"dependencies": {
"@babel/runtime": "^7.20.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 19.0.0",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@ -2612,6 +2676,11 @@
"react-dom": ">=16.8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -2985,6 +3054,14 @@
"node": ">=14.8.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -3262,6 +3339,14 @@
"@babel/helper-plugin-utils": "^7.19.0"
}
},
"@babel/runtime": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz",
"integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"@babel/template": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
@ -4411,6 +4496,22 @@
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true
},
"html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"requires": {
"void-elements": "3.1.0"
}
},
"i18next": {
"version": "22.4.15",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.4.15.tgz",
"integrity": "sha512-yYudtbFrrmWKLEhl6jvKUYyYunj4bTBCe2qIUYAxbXoPusY7YmdwPvOE6fx6UIfWvmlbCWDItr7wIs8KEBZ5Zg==",
"requires": {
"@babel/runtime": "^7.20.6"
}
},
"ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -4761,6 +4862,15 @@
"scheduler": "^0.23.0"
}
},
"react-i18next": {
"version": "12.2.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-12.2.2.tgz",
"integrity": "sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==",
"requires": {
"@babel/runtime": "^7.20.6",
"html-parse-stringify": "^3.0.1"
}
},
"react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@ -4784,6 +4894,11 @@
"react-router": "6.11.0"
}
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -5005,6 +5120,11 @@
"fast-glob": "^3.2.7"
}
},
"void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -10,9 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"i18next": "^22.4.15",
"react": "^18.2.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.2.2",
"react-router-dom": "^6.11.0"
},
"devDependencies": {

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import routes from '../../routes'
import Policy from '../Policy'
import Purposes from '../Purposes'
@ -9,7 +10,8 @@ import MainButton from '../MainButton'
import './styles.css'
function BirthdayPage(): JSX.Element {
const navigate = useNavigate();
const { t } = useTranslation()
const navigate = useNavigate()
const [birthdate, setBirthdate] = useState('')
const [isDisabled, setIsDisabled] = useState(true)
const links = [
@ -29,19 +31,17 @@ function BirthdayPage(): JSX.Element {
return (
<section className='page'>
<Title variant='h3' className='mt-24'>Let's start!</Title>
<Title variant='h2'>What's your date of birth?</Title>
<Title variant='h3' className='mt-24'>{t('letsStart')}</Title>
<Title variant='h2'>{t('dateOfBirth')}</Title>
<DatePicker
name='birthdate'
value={birthdate}
onValid={handleValid}
onInvalid={() => setIsDisabled(true)}
/>
<MainButton label='Next' onClick={handleNext} disabled={isDisabled}/>
<MainButton label={t('next')} onClick={handleNext} disabled={isDisabled}/>
<footer className='footer'>
<Policy links={links}>
By continuing, you agree to our EULA and Privacy Notice. Have a question? Reach our support team here
</Policy>
<Policy links={links}>{t('privacyText')}</Policy>
<Purposes />
</footer>
</section>

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useTranslation } from 'react-i18next'
import Title from "../Title"
import MainButton from "../MainButton"
import { TimePicker } from "../DateTimePicker"
@ -7,6 +8,7 @@ import routes from "../../routes"
import './styles.css'
function BirthtimePage(): JSX.Element {
const { t } = useTranslation()
const navigate = useNavigate();
const [birthtime, setBirhtime] = useState('')
const [isDisabled, setIsDisabled] = useState(true)
@ -23,12 +25,10 @@ function BirthtimePage(): JSX.Element {
return (
<section className='page'>
<Title variant="h2" className="mt-24">What time were you born?</Title>
<p className="description">
We use NASA data to determine the exact position of the planets in the sky at the time of your birth to create wallpapers that are just right for you.
</p>
<Title variant="h2" className="mt-24">{t('bornTimeQuestion')}</Title>
<p className="description">{t('nasaDataUsing')}</p>
<TimePicker onChange={(value: string) => setBirhtime(value)}/>
<MainButton label='Next' onClick={handleNext} disabled={isDisabled}/>
<MainButton label={t('next')} onClick={handleNext} disabled={isDisabled}/>
</section>
)
}

View File

@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'
import './styles.css'
function CallToAction(): JSX.Element {
const { t } = useTranslation()
return (
<div className='call-to-action mb-24'>
<h1>Start your 7-day trial</h1>
<p>No pressure. Cancel anytime.</p>
<h1>{t('ctaTitle')}</h1>
<p>{t('ctaSubtitle')}</p>
</div>
)
}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import './styles.css'
type CountdownProps = {
@ -6,6 +7,7 @@ type CountdownProps = {
}
function Countdown({ start }: CountdownProps): JSX.Element {
const { t } = useTranslation()
const [time, setTime] = useState(start * 60 - 1)
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
@ -21,7 +23,7 @@ function Countdown({ start }: CountdownProps): JSX.Element {
return (
<div className="countdown mb-24">
<p>Reserved for {formatTime(time)}</p>
<p>{t('reservedFor')}{formatTime(time)}</p>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import { useTranslation } from 'react-i18next'
import ProcessFlow from "./ProcessFlow"
import Title from "../Title"
import routes from "../../routes"
@ -9,12 +10,13 @@ import './styles.css'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function CreateProfilePage(): JSX.Element {
const { t } = useTranslation()
const navigate = useNavigate()
const [progress, setProgress] = useState(0)
const processItems = [
{ task: () => sleep(3300).then(() => setProgress(35)), label: 'Zodiac data analysis' },
{ task: () => sleep(2550).then(() => setProgress(61)), label: 'Drawing Wallpapers' },
{ task: () => sleep(3789).then(() => setProgress(98)), label: 'Preparing results' },
{ task: () => sleep(3300).then(() => setProgress(35)), label: t('zodiacAnalysis') },
{ task: () => sleep(2550).then(() => setProgress(61)), label: t('drawingWallpaper') },
{ task: () => sleep(3789).then(() => setProgress(98)), label: t('preparingResults') },
]
const handleDone = () => Promise.resolve()
.then(() => setProgress(100))
@ -23,7 +25,7 @@ function CreateProfilePage(): JSX.Element {
return (
<section className='page'>
<Title variant="h2" className="mt-24">Creating your profile</Title>
<Title variant="h2" className="mt-24">{t('creatingProfile')}</Title>
<div className="progressbar">
<CircularProgressbar
value={progress}

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormField } from '../../types'
import DateInput from './DateInput'
import ErrorText from './ErrorText'
import { stringify, getMaxYear, isNotTheDate, getDaysInMonth } from './utils'
export function DatePicker(props: FormField<string>): JSX.Element {
const { t } = useTranslation()
const { name, value, onValid, onInvalid } = props
const date = new Date(value)
@ -35,7 +37,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
value={year}
max={getMaxYear()}
maxLength={4}
label='Year'
label={t('year')}
placeholder='YYYY'
onChange={(year: number) => setYear(year)}
/>
@ -44,7 +46,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
value={month}
max={12}
maxLength={2}
label='Month'
label={t('month')}
placeholder='MM'
onChange={(month: number) => setMonth(month)}
/>
@ -53,14 +55,14 @@ export function DatePicker(props: FormField<string>): JSX.Element {
value={day}
max={getDaysInMonth(year, month)}
maxLength={2}
label='Day'
label={t('day')}
placeholder='DD'
onChange={(day: number) => setDay(day)}
/>
</div>
<ErrorText
isShown={hasError}
message='Date not found. Please check your details and try again.'
message={t('invalidDate')}
/>
</form>
)

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Title from '../Title'
import Policy from '../Policy'
import EmailInput from '../EmailInput'
@ -7,6 +8,7 @@ import MainButton from '../MainButton'
import routes from '../../routes'
function EmailEnterPage(): JSX.Element {
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [isDisabled, setIsDisabled] = useState(true)
@ -26,21 +28,17 @@ function EmailEnterPage(): JSX.Element {
return (
<section className='page'>
<Title variant='h2' className='mt-24'>
We will email you a copy of your wallpaper for easy access.
</Title>
<Title variant='h2' className='mt-24'>{t('weWillEmailYou')}</Title>
<EmailInput
name="email"
value={email}
placeholder="Your Email"
placeholder={t('yourEmail')}
onValid={handleValidEmail}
onInvalid={() => setIsDisabled(true)}
/>
<p>We don't share any personal information.</p>
<Policy links={links} sizing='medium'>
By clicking "Continue" below, you agree to our EULA and Privacy Policy.
</Policy>
<MainButton label='Continue' onClick={handleClick} disabled={isDisabled} />
<p>{t('weDontShare')}</p>
<Policy links={links} sizing='medium'>{t('continueAgree')}</Policy>
<MainButton label={t('continue')} onClick={handleClick} disabled={isDisabled} />
</section>
)
}

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import routes, { isNotEntrypoint } from '../../routes'
import BackButton from '../BackButton'
import iconUrl from './icon.png'
import './styles.css'
function Header(): JSX.Element {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [initialPath, setInitialPath] = useState<string | null>(null);
@ -33,7 +35,7 @@ function Header(): JSX.Element {
<header className="header">
{ showBackButton ? <BackButton className="pa" onClick={goBack} /> : null }
<img src={iconUrl} alt="logo" width="40" height="40" />
<span className="header__title">Aura</span>
<span className="header__title">{t('appName')}</span>
</header>
)
}

View File

@ -1,8 +1,11 @@
import { useTranslation } from 'react-i18next'
function NotFoundPage() {
const { t } = useTranslation()
return (
<>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<h1>{t('oops')}</h1>
<p>{t('unexpectedError')}</p>
</>
)
}

View File

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import Price, { Currency, Locale } from './Price'
import './styles.css'
@ -14,6 +15,7 @@ type PaymentProps = {
}
function Payment({ currency, locale, items }: PaymentProps): JSX.Element {
const { t } = useTranslation()
const total = items.reduce((acc, item) => acc + item.price, 0)
const totalPrice = new Price(total, currency, locale)
const toItem = (item: typeof items[0], idx: number) => {
@ -35,16 +37,14 @@ function Payment({ currency, locale, items }: PaymentProps): JSX.Element {
<div className='payment'>
<div className='payment__table'>
<div className='payment__total'>
<div className='payment__total-title'>Total today</div>
<div className='payment__total-title'>{t('totalToday')}</div>
<div className='payment__total-price'>{totalPrice.format()}</div>
</div>
<div className='payment__items'>
{items.map(toItem)}
</div>
</div>
<div className='payment__information'>
You will be charged only $1 for your 7-day trial. We'll email you a reminder before your trial period ends. Cancel anytime.
</div>
<div className='payment__information'>{t('chargedOnly')}</div>
</div>
)
}

View File

@ -1,7 +1,9 @@
import { useTranslation } from 'react-i18next'
import './styles.css'
function Purposes(): JSX.Element {
return <small className="purposes">For entertaiment purposes only</small>
const { t } = useTranslation()
return <small className="purposes">{t('purposes')}</small>
}
export default Purposes

View File

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import MainButton from '../MainButton'
import Policy from '../Policy'
import Countdown from '../Countdown'
@ -6,6 +7,7 @@ import UserHeader from '../UserHeader'
import CallToAction from '../CallToAction'
function SubscriptionPage(): JSX.Element {
const { t } = useTranslation()
const userEmail = 'some@email.com'
const links = [
{ text: 'Subscription policy', href: 'https://aura.wit.life/' },
@ -27,10 +29,8 @@ function SubscriptionPage(): JSX.Element {
<CallToAction />
<Countdown start={10}/>
<Payment items={paymentItems} currency={currency} locale={locale}/>
<MainButton label='Get access' onClick={handleClick} />
<Policy links={links}>
By proceeding, you agree that if you do not cancel your subscription before the end of the 7-day trial period, you will be automatically charged nineteen US dollars zero cents every 2 weeks until you cancel the subscription in the settings. Learn more about cancellation and refund policy in Subscription policy
</Policy>
<MainButton label={t('getAccess')} onClick={handleClick} />
<Policy links={links}>{t('subscriptionPolicy')}</Policy>
</section>
</>
)

View File

@ -1,13 +1,26 @@
import React from 'react'
import { BrowserRouter } from 'react-router-dom'
import i18next from 'i18next'
import { I18nextProvider, initReactI18next } from 'react-i18next'
import resources from './locales'
import routes from './routes'
import App from './components/App'
const init = async () => {
const response = await fetch(routes.server.translations())
const data = await response.json()
const defaultLanguage = data.meta.locale
// TODO: add translations from data.translations
const i18nextInstance = i18next.createInstance()
const options = { lng: defaultLanguage, resources }
await i18nextInstance.use(initReactI18next).init(options)
return (
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<I18nextProvider i18n={i18nextInstance}>
<BrowserRouter>
<App />
</BrowserRouter>
</I18nextProvider>
</React.StrictMode>
)
}

34
src/locales/en.ts Normal file
View File

@ -0,0 +1,34 @@
export default {
translation: {
letsStart: "Let's start!",
next: "Next",
dateOfBirth: "What's your date of birth?",
privacyText: "By continuing, you agree to our EULA and Privacy Notice. Have a question? Reach our support team here",
bornTimeQuestion: "What time were you born?",
nasaDataUsing: "We use NASA data to determine the exact position of the planets in the sky at the time of your birth to create wallpapers that are just right for you.",
ctaTitle: "Start your 7-day trial",
ctaSubtitle: "No pressure. Cancel anytime.",
reservedFor: "Reserved for ",
creatingProfile: "Creating your profile",
zodiacAnalysis: "Zodiac data analysis",
drawingWallpaper: "Drawing Wallpapers",
preparingResults: "Preparing results",
invalidDate: "Date not found. Please check your details and try again.",
year: "Year",
month: "Month",
day: "Day",
weWillEmailYou: "We will email you a copy of your wallpaper for easy access.",
yourEmail: "Your email",
weDontShare: "We don't share any personal information.",
continueAgree: 'By clicking "Continue" below, you agree to our EULA and Privacy Policy.',
continue: 'Continue',
appName: "Aura",
unexpectedError: 'Sorry, an unexpected error has occurred.',
oops: "Oops!",
totalToday: 'Total today',
chargedOnly: "You will be charged only $1 for your 7-day trial. We'll email you a reminder before your trial period ends. Cancel anytime.",
purposes: 'For entertaiment purposes only.',
getAccess: 'Get access',
subscriptionPolicy: 'By proceeding, you agree that if you do not cancel your subscription before the end of the 7-day trial period, you will be automatically charged nineteen US dollars zero cents every 2 weeks until you cancel the subscription in the settings. Learn more about cancellation and refund policy in Subscription policy',
},
}

3
src/locales/index.ts Normal file
View File

@ -0,0 +1,3 @@
import en from './en.ts'
export default { en }

View File

@ -1,5 +1,6 @@
const host = '';
const prefix = 'api/v1';
const host = ''
const apiHost = 'https://aura.wit.life'
const prefix = 'api/v1'
const routes = {
client: {
@ -11,9 +12,9 @@ const routes = {
createProfile: () => [host, 'profile', 'create'].join('/'),
},
server: {
locales: () => [host, prefix, 'locales.json'].join('/'),
translations: () => [host, prefix, 't.json'].join('/'),
userRegistration: () => [host, prefix, 'user', 'registration.json'].join('/'),
locales: () => [apiHost, prefix, 'locales.json'].join('/'),
translations: () => [apiHost, prefix, 't.json'].join('/'),
userRegistration: () => [apiHost, prefix, 'user', 'registration.json'].join('/'),
},
}

View File

@ -1,8 +1,8 @@
export interface FormField<T> {
name: string
value: T
label?: string
placeholder?: string
label?: string | null
placeholder?: string | null
onValid: (value: T) => void
onInvalid: () => void
}