Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ on:
- "frontend/**"

jobs:
lint:
name: Lint Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: ./frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run ESLint
run: npm run lint

- name: Run TypeScript check
run: npx tsc --noEmit

build:
name: Build Image Without Push
runs-on: ubuntu-latest
Expand Down
77 changes: 68 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ All frontend commands run from `frontend/`:
cd frontend
npm run dev # Start Next.js dev server
npm run build # Production build
npm run lint # ESLint (next lint)
npm run lint # ESLint 9 (flat config)
```

Docker-based dev environment (requires mise):
Expand Down Expand Up @@ -87,18 +87,48 @@ Frontend uses standalone Next.js output for Docker. Build args for map config ar

**Context Typing**: All React contexts must be fully typed:
```typescript
import { Context, createContext } from 'react'
import { MyContextValue } from '@/types'
import { createContext } from 'react'
import type { Context } from 'react'
import type { MyContextValue } from '@/types'

export const MyContext: Context<MyContextValue> = createContext<MyContextValue>({
// ... default values with proper types
})
```

**Component Props**: Explicitly type all component props, preferring imported types from `@/types`:
**Component Props**: Always define a separate type for component props with the naming pattern `ComponentNameProps`:
```typescript
import { MyComponentProps } from '@/types'
export const MyComponent = ({ prop1, prop2 }: MyComponentProps) => { ... }
export type MyComponentProps = {
title: string
count: number
onSubmit?: () => void
}

export function MyComponent({ title, count, onSubmit }: MyComponentProps) {
// ...
}
```

For shared component props, import from `@/types`:
```typescript
import type { MyComponentProps } from '@/types'

export function MyComponent({ prop1, prop2 }: MyComponentProps) {
// ...
}
```

**Component Declarations**: Use regular function declarations instead of arrow functions:
```typescript
// Preferred
export function MyComponent({ prop1, prop2 }: MyComponentProps) {
// ...
}

// Avoid
export const MyComponent = ({ prop1, prop2 }: MyComponentProps) => {
// ...
}
```

**Null Safety**: Use optional chaining and nullish coalescing:
Expand All @@ -114,8 +144,37 @@ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { ... }

**Form Data**: Use `z.infer<typeof schema>` for React Hook Form + Zod type extraction

**Type Imports**: Always use separate `import type` statements for type-only imports. This is enforced by both `verbatimModuleSyntax` in tsconfig and `@typescript-eslint/consistent-type-imports` ESLint rule:
```typescript
// Correct — separate import type statement
import { useModals } from '@mantine/modals'
import type { ContextModalProps } from '@mantine/modals'

// Wrong — inline type keyword
import { type ContextModalProps, useModals } from '@mantine/modals'

// Wrong — type imported as value
import { ContextModalProps, useModals } from '@mantine/modals'
```

**Explicit `any` is Forbidden**: Never use explicit `any` types in the codebase:
- Use proper type definitions from `@/types` or create new ones
- For complex types, use `unknown` and narrow with type guards, or define proper interfaces
- For truly dynamic data, use `Record<string, unknown>` instead of `any`
- Third-party library types should use their exported types or be properly typed
- Enforced by ESLint `@typescript-eslint/no-explicit-any` rule

**When Adding New Code**:
1. Run `npx tsc --noEmit` to verify types before committing
2. Never use `any` unless absolutely necessary (e.g., complex third-party types)
3. Import shared types from `@/types` rather than defining inline
4. Add return types to exported functions and React components
2. Run `npm run lint` to check ESLint rules
3. NEVER use explicit `any` - see "Explicit `any` is Forbidden" section above
4. Import shared types from `@/types` using `import type` syntax
5. Add return types to exported functions and React components

## Linting

**ESLint 9** with flat config (`eslint.config.mjs`). Key rules:
- `@typescript-eslint/no-explicit-any: error` — no `any` types
- `@typescript-eslint/consistent-type-imports: error` — enforce `import type` on separate lines
- `@typescript-eslint/no-unused-vars: warn` — unused variables (prefix with `_` to ignore)
- `react-hooks/exhaustive-deps` — complete dependency arrays in hooks
6 changes: 0 additions & 6 deletions frontend/.eslintrc.json

This file was deleted.

36 changes: 36 additions & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import nextConfig from "eslint-config-next"
import nextCoreWebVitals from "eslint-config-next/core-web-vitals"
import nextTypescript from "eslint-config-next/typescript"

const eslintConfig = [
...nextConfig,
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
},
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
},
],
},
},
{
ignores: [".next/", "node_modules/", "out/", "temp/"],
},
]

export default eslintConfig
2 changes: 1 addition & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"validate": "npm run type-check && npm run lint",
"api": " cd ../backend && npm run dev"
},
"dependencies": {
Expand Down Expand Up @@ -36,7 +39,7 @@
"@types/qs": "^6.9.15",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"eslint": "^9",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.1",
"postcss": "^8.4.33",
"postcss-preset-mantine": "^1.13.0",
Expand Down
15 changes: 10 additions & 5 deletions frontend/src/components/AddButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { FormContext } from '@/contexts/form';
import { Popover, Button, Center, Box } from '@mantine/core';
import { useModals } from '@mantine/modals';
import { useCallback, useContext } from 'react';
import { useContext } from 'react';
import type { FormData } from '@/types';

export const AddButton: React.FC<{ style?: React.CSSProperties }> = ({ style = {
export type AddButtonProps = {
style?: React.CSSProperties
}

export function AddButton({ style = {
position: 'absolute',
zIndex: 1,
bottom: '3rem',
left: '50%',
transform: 'translateX(-50%)',
} }) => {
} }: AddButtonProps) {
const modals = useModals()
const { data, setData } = useContext(FormContext)
const { addMode, setAddMode } = useContext(FormContext)
const onClick = useCallback((data) => {
const onClick = (data: FormData) => {
if (Object.keys(data).length == 0) {
setData({})
}
Expand All @@ -30,7 +35,7 @@ export const AddButton: React.FC<{ style?: React.CSSProperties }> = ({ style = {
},
}
)
}, [])
}

return (
<div style={style}>
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/EmailForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Button, Center, Group, Image, Overlay, Stack, Text, TextInput, Textarea, Title } from '@mantine/core'
import Link from 'next/link'
import { Button, Center, Group, Overlay, Stack, Text, TextInput, Textarea, Title } from '@mantine/core'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMedia } from 'react-use'
Expand All @@ -19,9 +18,9 @@ const formSchema = z.object({
text: z.string().min(1, 'Сообщение обязательно').max(999, 'Слишком длинное сообщение'),
})

export const EmailForm = () => {
export function EmailForm() {
const isMobile = useMedia('(max-width: 1024px)', false)
const { handleSubmit, control, register, formState } = useForm<z.infer<typeof formSchema>>({
const { handleSubmit, register, formState } = useForm<z.infer<typeof formSchema>>({
mode: 'onChange',
resolver: zodResolver(formSchema),
defaultValues: {
Expand All @@ -35,7 +34,7 @@ export const EmailForm = () => {
const onSubmit = async (data: z.infer<typeof formSchema>) => {
setText(states.fetch)

let dataFormatted = JSON.stringify(data)
const dataFormatted = JSON.stringify(data)

await fetch(
`/api/indexFeedback`,
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/Gallery/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { useDisclosure } from '@mantine/hooks'
import { useState } from 'react'
import NextImage from 'next/image'
import { useMedia } from 'react-use'
import type { GalleryImage } from '@/types'

const PrevButton = () => {
function PrevButton() {
const swiper = useSwiper()

return (
Expand All @@ -25,7 +26,11 @@ const PrevButton = () => {
)
}

export const Gallery = ({ galleryImages }) => {
export type GalleryProps = {
galleryImages: GalleryImage[]
}

export function Gallery({ galleryImages }: GalleryProps) {
const [imageOpened, { toggle: close, open }] = useDisclosure()
const [image, setImage] = useState(0)
const isMobile = useMedia('(max-width: 576px)', false)
Expand All @@ -39,12 +44,12 @@ export const Gallery = ({ galleryImages }) => {
slidesOffsetBefore={isMobile ? 10 : 68 / 2}
className={s.swiper}
centeredSlides={isMobile}
onClick={(swiper, e) => {
onClick={(swiper) => {
setImage((swiper.clickedIndex) % galleryImages.length)
open()
}}
>
{galleryImages.map((x, i) => (
{galleryImages.map((x) => (
<SwiperSlide
key={x.src}
>
Expand Down
32 changes: 15 additions & 17 deletions frontend/src/components/IdeaModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { FormContext } from '@/contexts/form'
import { Text, Stack, Button, Title, Center, Textarea, Tooltip } from '@mantine/core'
import { ContextModalProps, useModals } from '@mantine/modals'
import { useModals } from '@mantine/modals'
import type { ContextModalProps } from '@mantine/modals'
import { useRouter } from 'next/router'
import { useCallback, useContext, useEffect, useState } from 'react'
import { useContext, useEffect, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from "@hookform/resolvers/zod"
import { useSWRConfig } from 'swr'
import buttonStyles from '@/styles/button.module.css'
import type { IdeaModalDefaultValues } from '@/types'

export type IdeaModalProps = {
defaultValues?: { [key: string]: any }
defaultValues?: IdeaModalDefaultValues
}

const states = {
Expand All @@ -28,7 +30,7 @@ const formSchema = z.object({
}, { message: 'Добавьте точку' }),
})

export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProps<IdeaModalProps>) => {
export function IdeaModal({ id: modalId, innerProps }: ContextModalProps<IdeaModalProps>) {
const { mutate } = useSWRConfig()
const modals = useModals()
const formContext = useContext(FormContext)
Expand All @@ -42,7 +44,6 @@ export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProp
}
})
const [text, setText] = useState(states.start)
const [coordReq, setCoordReq] = useState(false)

const onSubmit = async (data: z.infer<typeof formSchema>) => {
const { coords } = formContext.data
Expand Down Expand Up @@ -119,25 +120,22 @@ export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProp
})
}

const onClickCoords = useCallback(
() => {
formContext.setData({
...formContext.data,
...getValues(),
})
modals.closeModal(modalId)
formContext.setAddMode(true)
},
[formContext.data]
)
const onClickCoords = () => {
formContext.setData({
...formContext.data,
...getValues(),
})
modals.closeModal(modalId)
formContext.setAddMode(true)
}

// close modal on route
const router = useRouter()
useEffect(() => {
if (router.pathname == '/') {
modals.closeAll()
}
}, [router.pathname])
}, [router.pathname, modals])

return (
<form
Expand Down
Loading