Add all pages except profile creating page
This commit is contained in:
parent
3b52ed4989
commit
5519769c73
@ -2,6 +2,8 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import BirthdayPage from '../BirthdayPage'
|
||||
import BirthtimePage from '../BirthtimePage'
|
||||
import CreateProfilePage from '../CreateProfilePage'
|
||||
import EmailEnterPage from '../EmailEnterPage'
|
||||
import SubscriptionPage from '../SubscriptionPage'
|
||||
import NotFoundPage from '../NotFoundPage'
|
||||
import Header from '../Header'
|
||||
import routes from '../../routes'
|
||||
@ -12,17 +14,17 @@ function App() {
|
||||
<div className='container'>
|
||||
<Header />
|
||||
<main className='content'>
|
||||
<section className='page'>
|
||||
<Routes>
|
||||
<Route path={routes.client.root()} element={
|
||||
<Navigate to={routes.client.birthday()} />
|
||||
} />
|
||||
<Route path={routes.client.birthday()} element={<BirthdayPage />} />
|
||||
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
|
||||
<Route path={routes.client.createProfile()} element={<CreateProfilePage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</section>
|
||||
<Routes>
|
||||
<Route path={routes.client.root()} element={
|
||||
<Navigate to={routes.client.birthday()} />
|
||||
} />
|
||||
<Route path={routes.client.birthday()} element={<BirthdayPage />} />
|
||||
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
|
||||
<Route path={routes.client.createProfile()} element={<CreateProfilePage />} />
|
||||
<Route path={routes.client.emailEnter()} element={<EmailEnterPage />} />
|
||||
<Route path={routes.client.subscription()} element={<SubscriptionPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -8,10 +8,9 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
|
||||
@ -1,26 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import routes from '../../routes'
|
||||
import Policy from '../Policy'
|
||||
import Purposes from '../Purposes'
|
||||
import Title from '../Title'
|
||||
import DateControl from '../DateControl'
|
||||
import { DatePicker } from '../DateTimePicker'
|
||||
import MainButton from '../MainButton'
|
||||
import './styles.css'
|
||||
|
||||
function BirthdayPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const [birthdate, setBirthdate] = useState('')
|
||||
const [isDisabled, setIsDisabled] = useState(true)
|
||||
const links = [
|
||||
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
||||
{ text: 'Privacy Notice', href: 'https://aura.wit.life/privacy' },
|
||||
{ text: 'here', href: 'https://aura.wit.life/' },
|
||||
]
|
||||
const handleNext = () => navigate(routes.client.birthtime())
|
||||
const handleValid = (birthdate: string) => {
|
||||
setBirthdate(birthdate)
|
||||
setIsDisabled(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('birthdate', birthdate)
|
||||
}, [birthdate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='page'>
|
||||
<Title variant='h3' className='mt-24'>Let's start!</Title>
|
||||
<Title variant='h2'>What's your date of birth?</Title>
|
||||
<DateControl />
|
||||
<MainButton label='Next' onClick={handleNext} />
|
||||
<DatePicker
|
||||
name='birthdate'
|
||||
value={birthdate}
|
||||
onValid={handleValid}
|
||||
onInvalid={() => setIsDisabled(true)}
|
||||
/>
|
||||
<MainButton label='Next' onClick={handleNext} disabled={isDisabled}/>
|
||||
<footer className='footer'>
|
||||
<Policy />
|
||||
<Policy links={links}>
|
||||
By continuing, you agree to our EULA and Privacy Notice. Have a question? Reach our support team here
|
||||
</Policy>
|
||||
<Purposes />
|
||||
</footer>
|
||||
</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,20 +1,35 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import Title from "../Title"
|
||||
import MainButton from "../MainButton"
|
||||
import TimeControl from "../TimeControl"
|
||||
import { TimePicker } from "../DateTimePicker"
|
||||
import routes from "../../routes"
|
||||
import './styles.css'
|
||||
|
||||
function BirthtimePage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const [birthtime, setBirhtime] = useState('')
|
||||
const [isDisabled, setIsDisabled] = useState(true)
|
||||
const handleNext = () => navigate(routes.client.createProfile())
|
||||
|
||||
useEffect(() => {
|
||||
if (!birthtime) {
|
||||
setIsDisabled(true)
|
||||
return
|
||||
}
|
||||
setIsDisabled(false)
|
||||
console.log('birthtime', birthtime)
|
||||
}, [birthtime])
|
||||
|
||||
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>
|
||||
<TimeControl />
|
||||
<MainButton label='Next' onClick={handleNext} />
|
||||
</>
|
||||
<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>
|
||||
<TimePicker onChange={(value: string) => setBirhtime(value)}/>
|
||||
<MainButton label='Next' onClick={handleNext} disabled={isDisabled}/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
12
src/components/CallToAction/index.tsx
Normal file
12
src/components/CallToAction/index.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import './styles.css'
|
||||
|
||||
function CallToAction(): JSX.Element {
|
||||
return (
|
||||
<div className='call-to-action mb-24'>
|
||||
<h1>Start your 7-day trial</h1>
|
||||
<p>No pressure. Cancel anytime.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CallToAction
|
||||
12
src/components/CallToAction/styles.css
Normal file
12
src/components/CallToAction/styles.css
Normal file
@ -0,0 +1,12 @@
|
||||
.call-to-action {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-to-action > h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.call-to-action > p {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
29
src/components/Countdown/index.tsx
Normal file
29
src/components/Countdown/index.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import './styles.css'
|
||||
|
||||
type CountdownProps = {
|
||||
start: number
|
||||
}
|
||||
|
||||
function Countdown({ start }: CountdownProps): JSX.Element {
|
||||
const [time, setTime] = useState(start * 60 - 1)
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (time === 0) return
|
||||
const timer = setTimeout(() => setTime(time - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [time])
|
||||
|
||||
return (
|
||||
<div className="countdown mb-24">
|
||||
<p>Reserved for {formatTime(time)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Countdown
|
||||
10
src/components/Countdown/styles.css
Normal file
10
src/components/Countdown/styles.css
Normal file
@ -0,0 +1,10 @@
|
||||
.countdown {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
padding: 10px 30px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@ -1,10 +1,20 @@
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import Title from "../Title"
|
||||
import routes from "../../routes"
|
||||
|
||||
function CreateProfilePage(): JSX.Element {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setTimeout(() => navigate(routes.client.emailEnter()), 3000)
|
||||
return () => clearTimeout(timerId)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='page'>
|
||||
<Title variant="h2" className="mt-24">Creating your profile</Title>
|
||||
</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import './styles.css'
|
||||
|
||||
function DateControl(): JSX.Element {
|
||||
return (
|
||||
<div className='date-control'>
|
||||
<div className='date-control__container'>
|
||||
<div className="date-control__field">
|
||||
<h3 className='date-control__field-label'>Year</h3>
|
||||
<label className="date-control__input">
|
||||
<input type="number" placeholder=" " max="4" />
|
||||
<p className="date-control__input-placeholder">YYYY</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-control__field">
|
||||
<h3 className='date-control__field-label'>Month</h3>
|
||||
<label className="date-control__input">
|
||||
<input type="text" placeholder=" " />
|
||||
<p className="date-control__input-placeholder">MM</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-control__field">
|
||||
<h3 className='date-control__field-label'>Day</h3>
|
||||
<label className="date-control__input">
|
||||
<input type="text" placeholder=" " />
|
||||
<p className="date-control__input-placeholder">DD</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateControl
|
||||
40
src/components/DateTimePicker/DateInput.tsx
Normal file
40
src/components/DateTimePicker/DateInput.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { FormField } from '../../types'
|
||||
import { normalize, calculateMaxValue } from './utils'
|
||||
|
||||
type DatePartValue = number | undefined
|
||||
|
||||
type DateInputProps = Omit<FormField<DatePartValue>, 'onValid' | 'onInvalid'> & {
|
||||
max: number
|
||||
maxLength: number
|
||||
onChange: (part: number) => void
|
||||
}
|
||||
|
||||
function DateInput(props: DateInputProps): JSX.Element {
|
||||
const { label, placeholder, name, value, max, maxLength, onChange } = props
|
||||
const validate = (value: number): boolean => value >= 0 && value <= max
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const datePart = parseInt(e.target.value, 10)
|
||||
if (isNaN(datePart)) return
|
||||
if (datePart > calculateMaxValue(maxLength)) return
|
||||
if (!validate(datePart)) return
|
||||
onChange(datePart)
|
||||
}
|
||||
return (
|
||||
<div className="date-picker__field">
|
||||
<h3 className='date-picker__field-label'>{label}</h3>
|
||||
<label className="date-picker__input">
|
||||
<input
|
||||
name={name}
|
||||
type="number"
|
||||
placeholder=" "
|
||||
maxLength={maxLength}
|
||||
value={value ? normalize(value, maxLength) : ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<p className="date-picker__input-placeholder">{placeholder}</p>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateInput
|
||||
67
src/components/DateTimePicker/DatePicker.tsx
Normal file
67
src/components/DateTimePicker/DatePicker.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { name, value, onValid, onInvalid } = props
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
const [year, setYear] = useState(date.getFullYear())
|
||||
const [month, setMonth] = useState(date.getMonth())
|
||||
const [day, setDay] = useState(date.getDate())
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return
|
||||
const combinedDate = `${year}-${month}-${day}`
|
||||
const date = new Date(combinedDate)
|
||||
if (isNotTheDate(date)) {
|
||||
setHasError(true)
|
||||
onInvalid()
|
||||
return
|
||||
}
|
||||
setHasError(false)
|
||||
onValid(stringify(date))
|
||||
}, [year, month, day, hasError, onValid, onInvalid])
|
||||
|
||||
return (
|
||||
<form name={name} className='date-picker'>
|
||||
<div className='date-picker__container'>
|
||||
<DateInput
|
||||
name='year'
|
||||
value={year}
|
||||
max={getMaxYear()}
|
||||
maxLength={4}
|
||||
label='Year'
|
||||
placeholder='YYYY'
|
||||
onChange={(year: number) => setYear(year)}
|
||||
/>
|
||||
<DateInput
|
||||
name='month'
|
||||
value={month}
|
||||
max={12}
|
||||
maxLength={2}
|
||||
label='Month'
|
||||
placeholder='MM'
|
||||
onChange={(month: number) => setMonth(month)}
|
||||
/>
|
||||
<DateInput
|
||||
name='day'
|
||||
value={day}
|
||||
max={getDaysInMonth(year, month)}
|
||||
maxLength={2}
|
||||
label='Day'
|
||||
placeholder='DD'
|
||||
onChange={(day: number) => setDay(day)}
|
||||
/>
|
||||
</div>
|
||||
<ErrorText
|
||||
isShown={hasError}
|
||||
message='Date not found. Please check your details and try again.'
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
15
src/components/DateTimePicker/ErrorText.tsx
Normal file
15
src/components/DateTimePicker/ErrorText.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
type ErrorTextProps = {
|
||||
isShown: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
function ErrorText({ message, isShown }: ErrorTextProps): JSX.Element {
|
||||
const className = isShown ? 'date-picker__error--shown' : ''
|
||||
return (
|
||||
<p className={`date-picker__error ${className}`}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorText
|
||||
53
src/components/DateTimePicker/TimePicker.tsx
Normal file
53
src/components/DateTimePicker/TimePicker.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { normalize } from "./utils"
|
||||
|
||||
type TimePickerProps = {
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function TimePicker({ onChange }: TimePickerProps): JSX.Element {
|
||||
const [hour, setHour] = useState('1')
|
||||
const [minute, setMinute] = useState('0')
|
||||
const [period, setPeriod] = useState('AM')
|
||||
|
||||
useEffect(() => {
|
||||
const formattedHour = period === 'AM' ? normalize(hour, 2) : String(Number(hour) + 12)
|
||||
const formattedMinute = normalize(minute, 2)
|
||||
onChange(`${formattedHour}:${formattedMinute}`)
|
||||
}, [hour, minute, period, onChange])
|
||||
|
||||
return (
|
||||
<div className='date-picker'>
|
||||
<div className='date-picker__container'>
|
||||
<div className="date-picker__field">
|
||||
<select
|
||||
className="date-picker__field-select"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setHour(e.target.value)}>
|
||||
{Array.from(Array(12).keys()).map((hour) => (
|
||||
<option key={hour} value={hour + 1}>{hour + 1}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="date-picker__field">
|
||||
<select
|
||||
className="date-picker__field-select"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setMinute(e.target.value)}>
|
||||
{Array.from(Array(60).keys()).map((minute) => {
|
||||
return (
|
||||
<option key={minute} value={minute}>{normalize(minute, 2)}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="date-picker__field">
|
||||
<select
|
||||
className="date-picker__field-select"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setPeriod(e.target.value)}>
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/components/DateTimePicker/index.ts
Normal file
5
src/components/DateTimePicker/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import './styles.css'
|
||||
|
||||
export * from './DatePicker'
|
||||
export * from './TimePicker'
|
||||
export * from './utils'
|
||||
@ -1,30 +1,30 @@
|
||||
.date-control {
|
||||
.date-picker {
|
||||
margin: 0 auto;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.date-control__container {
|
||||
.date-picker__container {
|
||||
grid-gap: 12px;
|
||||
background-color: #fff;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3,1fr);
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 3;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.date-control__field-label {
|
||||
.date-picker__field-label {
|
||||
color: #6b7baa;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.date-control__input {
|
||||
.date-picker__input {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
@ -33,7 +33,7 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.date-control__input > input {
|
||||
.date-picker__input > input {
|
||||
appearance: none;
|
||||
border-radius: 8px;
|
||||
color: #121620;
|
||||
@ -51,12 +51,12 @@
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.date-control__input > input:focus {
|
||||
.date-picker__input > input:focus {
|
||||
border-color: #066fde;
|
||||
transition-delay: .1s;
|
||||
}
|
||||
|
||||
.date-control__input-placeholder {
|
||||
.date-picker__input-placeholder {
|
||||
color: #6b7baa;
|
||||
font-size: 16px;
|
||||
left: 12px;
|
||||
@ -69,15 +69,15 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.date-control__input input:focus + .date-control__input-placeholder,
|
||||
.date-control__input input:not(:placeholder-shown) + .date-control__input-placeholder {
|
||||
.date-picker__input input:focus + .date-picker__input-placeholder,
|
||||
.date-picker__input input:not(:placeholder-shown) + .date-picker__input-placeholder {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
top: 12px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-control__field-select {
|
||||
.date-picker__field-select {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
@ -97,3 +97,19 @@
|
||||
border: 2px solid #dee5f9;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.date-picker__error {
|
||||
color: #ff5c5d;
|
||||
font-size: 12px;
|
||||
left: 12px;
|
||||
line-height: 16px;
|
||||
margin-left: 12px;
|
||||
position: absolute;
|
||||
transform: translateY(-32px);
|
||||
transition: all .5s;
|
||||
}
|
||||
|
||||
.date-picker__error--shown {
|
||||
position: static;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
23
src/components/DateTimePicker/utils.ts
Normal file
23
src/components/DateTimePicker/utils.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const normalize = (value: number | string, max: number): string => {
|
||||
return String(value).padStart(max, '0')
|
||||
}
|
||||
|
||||
export const calculateMaxValue = (digits: number): number => {
|
||||
return Math.pow(10, digits) - 1;
|
||||
}
|
||||
|
||||
export const getMaxYear = (): number => {
|
||||
return new Date().getFullYear()
|
||||
}
|
||||
|
||||
export const stringify = (value: Date): string => {
|
||||
return value.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export const isNotTheDate = (date: Date) => {
|
||||
return date.toString() === 'Invalid Date'
|
||||
}
|
||||
|
||||
export const getDaysInMonth = (year: number, month: number): number => {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
48
src/components/EmailEnterPage/index.tsx
Normal file
48
src/components/EmailEnterPage/index.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Title from '../Title'
|
||||
import Policy from '../Policy'
|
||||
import EmailInput from '../EmailInput'
|
||||
import MainButton from '../MainButton'
|
||||
import routes from '../../routes'
|
||||
|
||||
function EmailEnterPage(): JSX.Element {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [isDisabled, setIsDisabled] = useState(true)
|
||||
const links = [
|
||||
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
||||
{ text: 'Privacy Policy', href: 'https://aura.wit.life/privacy' },
|
||||
]
|
||||
const handleValidEmail = (email: string) => {
|
||||
setEmail(email)
|
||||
setIsDisabled(false)
|
||||
}
|
||||
const handleClick = () => navigate(routes.client.subscription())
|
||||
|
||||
useEffect(() => {
|
||||
console.log('email', email)
|
||||
}, [email])
|
||||
|
||||
return (
|
||||
<section className='page'>
|
||||
<Title variant='h2' className='mt-24'>
|
||||
We will email you a copy of your wallpaper for easy access.
|
||||
</Title>
|
||||
<EmailInput
|
||||
name="email"
|
||||
value={email}
|
||||
placeholder="Your Email"
|
||||
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} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailEnterPage
|
||||
37
src/components/EmailInput/index.tsx
Normal file
37
src/components/EmailInput/index.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { FormField } from '../../types'
|
||||
import './styles.css'
|
||||
|
||||
const isValidEmail = (email: string) => {
|
||||
const re = /^(([^<>()[\]\\.,:\s@"]+(\.[^<>()[\]\\.,:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return re.test(String(email).toLowerCase().trim())
|
||||
}
|
||||
|
||||
function EmailInput(props: FormField<string>): JSX.Element {
|
||||
const { name, value, placeholder, onValid, onInvalid } = props
|
||||
const [email, setEmail] = useState(value)
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputEmail = event.target.value
|
||||
setEmail(inputEmail)
|
||||
if (isValidEmail(inputEmail)) {
|
||||
onValid(inputEmail)
|
||||
} else {
|
||||
onInvalid()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="email-input">
|
||||
<input
|
||||
name={name}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
placeholder=" "
|
||||
/>
|
||||
<span className="email-input__placeholder">{placeholder}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailInput
|
||||
47
src/components/EmailInput/styles.css
Normal file
47
src/components/EmailInput/styles.css
Normal file
@ -0,0 +1,47 @@
|
||||
.email-input {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
max-width: 400px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.email-input > input {
|
||||
appearance: none;
|
||||
border: 1px solid #c7c7c7;
|
||||
border-radius: 8px;
|
||||
color: #121620;
|
||||
font-size: 16px;
|
||||
height: 48px;
|
||||
line-height: 18px;
|
||||
outline: none;
|
||||
padding: 12px 12px 5px;
|
||||
transition: border-color .3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-input > input:focus {
|
||||
border-color: #000;
|
||||
transition-delay: .1s;
|
||||
}
|
||||
|
||||
.email-input > input:focus + .email-input__placeholder,
|
||||
.email-input > input:not(:placeholder-shown) + .email-input__placeholder {
|
||||
font-size: 12px;
|
||||
top: 12px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.email-input__placeholder {
|
||||
color: #8e8e93;
|
||||
font-size: 16px;
|
||||
left: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: top .3s ease,color .3s ease,font-size .3s ease;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@ -33,6 +33,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>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,3 +8,10 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
44
src/components/Payment/Price.ts
Normal file
44
src/components/Payment/Price.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export enum Currency {
|
||||
USD = 'USD',
|
||||
EUR = 'EUR',
|
||||
}
|
||||
|
||||
export enum Locale {
|
||||
EN = 'en-US',
|
||||
FR = 'fr-FR',
|
||||
}
|
||||
|
||||
class Price {
|
||||
private _value: number
|
||||
private _currency: Currency
|
||||
private _locale: Locale
|
||||
|
||||
constructor(value: number, currency: Currency, locale: Locale = Locale.EN) {
|
||||
this._value = value
|
||||
this._currency = currency
|
||||
this._locale = locale
|
||||
}
|
||||
|
||||
format(): string {
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
style: 'currency',
|
||||
currency: this._currency,
|
||||
};
|
||||
return new Intl.NumberFormat(this._locale, options).format(this._value)
|
||||
}
|
||||
|
||||
toSentence(): string {
|
||||
// TODO: implement
|
||||
// 1.36 => One dollar thirty six cents
|
||||
// 1.00 => One dollar
|
||||
// 1.01 => One dollar one cent
|
||||
// 2.00 => Two dollars
|
||||
// 2.01 => Two dollars one cent
|
||||
// 2.02 => Two dollars two cents
|
||||
// 2.10 => Two dollars ten cents
|
||||
// and so one...
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default Price
|
||||
53
src/components/Payment/index.tsx
Normal file
53
src/components/Payment/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import Price, { Currency, Locale } from './Price'
|
||||
import './styles.css'
|
||||
|
||||
type PaymentItem = {
|
||||
title: string
|
||||
price: number
|
||||
description: string
|
||||
}
|
||||
|
||||
type PaymentProps = {
|
||||
currency: Currency
|
||||
locale: Locale
|
||||
items: PaymentItem[]
|
||||
}
|
||||
|
||||
function Payment({ currency, locale, items }: PaymentProps): JSX.Element {
|
||||
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) => {
|
||||
const price = new Price(item.price, currency, locale)
|
||||
return (
|
||||
<div key={idx} className='payment__item'>
|
||||
<div className='payment__item-summary'>
|
||||
<div className='payment__item-title'>{item.title}</div>
|
||||
<div className='payment__item-price'>{price.format()}</div>
|
||||
</div>
|
||||
<div className='payment__item-description'>
|
||||
<p>{item.description}</p>
|
||||
<p>One dollar thirty six cents per day</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='payment'>
|
||||
<div className='payment__table'>
|
||||
<div className='payment__total'>
|
||||
<div className='payment__total-title'>Total today</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Payment
|
||||
export { Price, Currency, Locale }
|
||||
60
src/components/Payment/styles.css
Normal file
60
src/components/Payment/styles.css
Normal file
@ -0,0 +1,60 @@
|
||||
.payment {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.payment__table {
|
||||
background: #fff;
|
||||
border: 2px solid #000;
|
||||
border-radius: 20px;
|
||||
color: #000;
|
||||
padding: 0 25px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.payment__total,
|
||||
.payment__item-summary {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.payment__total-price,
|
||||
.payment__item-price {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.payment__total {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #000;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.payment__items {
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.payment__item {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.payment__item-description {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.payment__item-summary {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.payment__information {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@ -1,39 +1,45 @@
|
||||
import { ReactNode } from 'react'
|
||||
import './styles.css'
|
||||
|
||||
enum PolicyContentType {
|
||||
Text = 'text',
|
||||
Link = 'link',
|
||||
type Link = {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
|
||||
type PolicyContent = {
|
||||
type: PolicyContentType
|
||||
content: string
|
||||
href?: string
|
||||
interface PolicyProps {
|
||||
links: Link[]
|
||||
children: string
|
||||
sizing?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
function Policy(): JSX.Element {
|
||||
const text: PolicyContent[] = [
|
||||
{ type: PolicyContentType.Text, content: 'By continuing, you agree to our ' },
|
||||
{ type: PolicyContentType.Link, content: 'EULA', href: 'https://aura.wit.life/terms' },
|
||||
{ type: PolicyContentType.Text, content: ' and ' },
|
||||
{ type: PolicyContentType.Link, content: 'Privacy Notice', href: 'https://aura.wit.life/privacy' },
|
||||
{ type: PolicyContentType.Text, content: '. Have a question? Reach our support team ' },
|
||||
{ type: PolicyContentType.Link, content: 'here', href: 'https://aura.wit.life/' },
|
||||
]
|
||||
const toElement = (item: PolicyContent, idx: number) => {
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
return item.content
|
||||
case 'link':
|
||||
return <a key={idx} href={item.href} target="_blank" rel="noreferrer nofollow">{item.content}</a>
|
||||
default:
|
||||
throw new Error(`Unknown type: ${item.type}`)
|
||||
}
|
||||
const sizes = {
|
||||
small: 'policy--small',
|
||||
medium: 'policy--medium',
|
||||
large: 'policy--large',
|
||||
}
|
||||
|
||||
function Policy({ links, children, sizing = 'small' }: PolicyProps): JSX.Element {
|
||||
const createLinkedContent = (sentence: string): ReactNode[] => {
|
||||
const pattern = links.map(link => `(${link.text})`).join('|');
|
||||
const regex = new RegExp(pattern, 'g');
|
||||
return sentence.split(regex).map((part, idx) => {
|
||||
const link = links.find(({ text }) => text === part);
|
||||
|
||||
if (!link) return part
|
||||
|
||||
return (
|
||||
<a key={`${link.text}-${idx}`} href={link.href} target="_blank" rel="noreferrer nofollow">
|
||||
{link.text}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
}
|
||||
const content = text.map<ReactNode>(toElement)
|
||||
|
||||
return <div className="policy"><p>{ content }</p></div>
|
||||
return (
|
||||
<div className={`policy ${sizes[sizing]}`}>
|
||||
<p>{createLinkedContent(children)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Policy
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.policy p,
|
||||
.policy a {
|
||||
color: #121620;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
@ -18,3 +18,19 @@
|
||||
.policy a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.policy--small p,
|
||||
.policy--small a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.policy--medium p,
|
||||
.policy--medium a {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.policy--large p,
|
||||
.policy--large a {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
@ -2,5 +2,4 @@
|
||||
color: #8e8e93;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
43
src/components/SubscriptionPage/index.tsx
Normal file
43
src/components/SubscriptionPage/index.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import Title from '../Title'
|
||||
import MainButton from '../MainButton'
|
||||
import Policy from '../Policy'
|
||||
import Countdown from '../Countdown'
|
||||
import Payment, { Currency, Locale } from '../Payment'
|
||||
import UserHeader from '../UserHeader'
|
||||
import CallToAction from '../CallToAction'
|
||||
|
||||
function SubscriptionPage(): JSX.Element {
|
||||
const userEmail = 'some@email.com'
|
||||
const links = [
|
||||
{ text: 'Subscription policy', href: 'https://aura.wit.life/' },
|
||||
]
|
||||
const currency = Currency.USD
|
||||
const locale = Locale.EN
|
||||
const paymentItems = [
|
||||
{
|
||||
title: 'Per 7-Day Trial For',
|
||||
price: 1.00,
|
||||
description: '2-Week Plan',
|
||||
},
|
||||
]
|
||||
const handleClick = () => console.log('What we will do?')
|
||||
return (
|
||||
<>
|
||||
<UserHeader email={userEmail} />
|
||||
<section className='page'>
|
||||
<Title variant='h3'>
|
||||
Your personalized Aries Wallpaper has been created! Find your happiness now and get an additional individual horoscope based on your energies.
|
||||
</Title>
|
||||
<Countdown start={10}/>
|
||||
<CallToAction />
|
||||
<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>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionPage
|
||||
@ -1,33 +0,0 @@
|
||||
function TimeControl(): JSX.Element {
|
||||
return (
|
||||
<div className='date-control'>
|
||||
<div className='date-control__container'>
|
||||
<div className="date-control__field">
|
||||
<select className="date-control__field-select">
|
||||
{Array.from(Array(12).keys()).map((hour) => (
|
||||
<option key={hour} value={hour + 1}>{hour + 1}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="date-control__field">
|
||||
<select className="date-control__field-select">
|
||||
{Array.from(Array(60).keys()).map((minute) => {
|
||||
const formattedMinute = String(minute).padStart(2, '0');
|
||||
return (
|
||||
<option key={minute} value={minute}>{formattedMinute}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="date-control__field">
|
||||
<select className="date-control__field-select">
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimeControl
|
||||
@ -1,5 +1,4 @@
|
||||
.title {
|
||||
letter-spacing: .2px;
|
||||
line-height: 150%;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
16
src/components/UserHeader/index.tsx
Normal file
16
src/components/UserHeader/index.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import './styles.css'
|
||||
|
||||
type UserHeaderProps = {
|
||||
email: string
|
||||
}
|
||||
|
||||
function UserHeader({ email }: UserHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section className='user-header'>
|
||||
<div className='user-header__content'>{email}</div>
|
||||
<div className='user-header__icon'>{email.at(0)?.toUpperCase()}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserHeader
|
||||
23
src/components/UserHeader/styles.css
Normal file
23
src/components/UserHeader/styles.css
Normal file
@ -0,0 +1,23 @@
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 32px;
|
||||
background: #c2ceee;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
height: 31px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-header__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #9babd9;
|
||||
border-radius: 50%;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
@ -3,6 +3,11 @@
|
||||
font-family: SF Pro Text, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
@ -89,6 +94,10 @@ a,button,div,input,select,textarea {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.mb-24 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pa {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@ const prefix = 'api/v1';
|
||||
const routes = {
|
||||
client: {
|
||||
root: () => [host, ''].join('/'),
|
||||
email: () => [host, 'email'].join('/'),
|
||||
birthday: () => [host, 'birthday'].join('/'),
|
||||
birthtime: () => [host, 'birthtime'].join('/'),
|
||||
emailEnter: () => [host, 'email'].join('/'),
|
||||
subscription: () => [host, 'subscription'].join('/'),
|
||||
createProfile: () => [host, 'profile', 'create'].join('/'),
|
||||
},
|
||||
|
||||
8
src/types.ts
Normal file
8
src/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface FormField<T> {
|
||||
name: string
|
||||
value: T
|
||||
label?: string
|
||||
placeholder?: string
|
||||
onValid: (value: T) => void
|
||||
onInvalid: () => void
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user