- Updated button text to reflect loading states in SignIn, SignUp, and Tracker pages. - Adjusted padding and heading sizes for better responsiveness in Main, Stats, and Tracker pages. - Improved layout consistency by modifying padding properties in Card components.
265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
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={{ base: 2, md: 8 }}>
|
||
<VStack gap={6} maxW="4xl" mx="auto">
|
||
<Heading size={{ base: 'xl', md: '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 p={{ base: 4, md: 6 }}>
|
||
<VStack gap={4}>
|
||
<Text fontSize="lg" fontWeight="bold">
|
||
Быстрая запись
|
||
</Text>
|
||
<Button
|
||
colorScheme="blue"
|
||
size="lg"
|
||
w="full"
|
||
onClick={logQuick}
|
||
disabled={isLoading}
|
||
>
|
||
{isLoading ? 'Запись...' : 'Записать сигарету (текущее время)'}
|
||
</Button>
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{/* Form with custom time and note */}
|
||
<Card.Root w="full">
|
||
<Card.Body p={{ base: 4, md: 6 }}>
|
||
<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"
|
||
disabled={isLoading}
|
||
>
|
||
{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 p={{ base: 4, md: 6 }}>
|
||
<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>
|
||
)
|
||
}
|
||
|