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:
Primakov Alexandr Alexandrovich
2025-11-17 13:53:25 +03:00
parent c3eab8bcac
commit debd28905a
19 changed files with 1947 additions and 26 deletions
+266
View File
@@ -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>
)
}