Add authentication and tracking features with API integration
- Implemented user authentication with signup and signin functionality. - Created a context for managing authentication state. - Added protected routes for accessing the dashboard and tracker pages. - Developed a tracker page for logging cigarette usage with optional notes and timestamps. - Introduced a statistics page to visualize daily smoking habits using charts. - Integrated Axios for API requests and error handling. - Updated package dependencies including React Hook Form and Zod for form validation. - Enhanced UI components for better user experience with Chakra UI. - Added routing for authentication and tracking pages.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Card,
|
||||
Textarea,
|
||||
Stack,
|
||||
} from '@chakra-ui/react'
|
||||
import { Field } from '../../components/ui/field'
|
||||
import { cigarettesApi } from '../../api/client'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import type { Cigarette } from '../../types/api'
|
||||
|
||||
const logCigaretteSchema = z.object({
|
||||
smokedAt: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
type LogCigaretteFormData = z.infer<typeof logCigaretteSchema>
|
||||
|
||||
export const TrackerPage: React.FC = () => {
|
||||
const [cigarettes, setCigarettes] = useState<Cigarette[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<LogCigaretteFormData>({
|
||||
resolver: zodResolver(logCigaretteSchema),
|
||||
})
|
||||
|
||||
const fetchCigarettes = async () => {
|
||||
try {
|
||||
const response = await cigarettesApi.getAll()
|
||||
if (response.success) {
|
||||
// Show most recent first
|
||||
setCigarettes(response.body.reverse())
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch cigarettes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCigarettes()
|
||||
}, [])
|
||||
|
||||
const logQuick = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await cigarettesApi.log({})
|
||||
if (response.success) {
|
||||
setSuccess('Сигарета записана!')
|
||||
await fetchCigarettes()
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.errors || err?.response?.data?.message || 'Ошибка при записи'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: LogCigaretteFormData) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await cigarettesApi.log({
|
||||
smokedAt: data.smokedAt || undefined,
|
||||
note: data.note || undefined,
|
||||
})
|
||||
if (response.success) {
|
||||
setSuccess('Сигарета записана с заметкой!')
|
||||
reset()
|
||||
await fetchCigarettes()
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.errors || err?.response?.data?.message || 'Ошибка при записи'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg="gray.50" p={8}>
|
||||
<VStack gap={6} maxW="4xl" mx="auto">
|
||||
<Heading size="2xl">Трекер курения</Heading>
|
||||
|
||||
<HStack w="full" gap={4}>
|
||||
<Link to={URLs.baseUrl}>
|
||||
<Button colorScheme="gray" variant="outline">
|
||||
На главную
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={URLs.baseUrl + '/stats'}>
|
||||
<Button colorScheme="teal" variant="outline">
|
||||
Статистика
|
||||
</Button>
|
||||
</Link>
|
||||
</HStack>
|
||||
|
||||
{/* Quick log button */}
|
||||
<Card.Root w="full" bg="blue.50">
|
||||
<Card.Body>
|
||||
<VStack gap={4}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
Быстрая запись
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
w="full"
|
||||
onClick={logQuick}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Записать сигарету (текущее время)
|
||||
</Button>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Form with custom time and note */}
|
||||
<Card.Root w="full">
|
||||
<Card.Body>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack gap={4} align="stretch">
|
||||
<Heading size="md">Запись с дополнительными данными</Heading>
|
||||
|
||||
<Field
|
||||
label="Время (необязательно)"
|
||||
helperText="Оставьте пустым для текущего времени"
|
||||
invalid={!!errors.smokedAt}
|
||||
errorText={errors.smokedAt?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('smokedAt')}
|
||||
type="datetime-local"
|
||||
placeholder="Выберите время"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Заметка (необязательно)"
|
||||
invalid={!!errors.note}
|
||||
errorText={errors.note?.message}
|
||||
>
|
||||
<Textarea
|
||||
{...register('note')}
|
||||
placeholder="Добавьте комментарий..."
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="green"
|
||||
w="full"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Записать с заметкой
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
{/* Success/Error messages */}
|
||||
{success && (
|
||||
<Card.Root w="full" bg="green.50" borderColor="green.500" borderWidth={2}>
|
||||
<Card.Body>
|
||||
<Text color="green.700" fontWeight="bold">
|
||||
{success}
|
||||
</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card.Root w="full" bg="red.50" borderColor="red.500" borderWidth={2}>
|
||||
<Card.Body>
|
||||
<Text color="red.700" fontWeight="bold">
|
||||
{error}
|
||||
</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
{/* Recent cigarettes list */}
|
||||
<Card.Root w="full">
|
||||
<Card.Body>
|
||||
<VStack gap={4} align="stretch">
|
||||
<Heading size="md">Последние записи</Heading>
|
||||
|
||||
{cigarettes.length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center" py={4}>
|
||||
Записей пока нет
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
{cigarettes.slice(0, 10).map((cigarette) => (
|
||||
<Box
|
||||
key={cigarette.id}
|
||||
p={3}
|
||||
bg="gray.100"
|
||||
borderRadius="md"
|
||||
borderWidth={1}
|
||||
borderColor="gray.300"
|
||||
>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontWeight="bold">
|
||||
{formatDate(cigarette.smokedAt)}
|
||||
</Text>
|
||||
{cigarette.note && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{cigarette.note}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user