Merge branch 'refactor'

This commit is contained in:
@kirill.komarov
2026-05-13 22:07:46 +05:00
parent 3c9797af4a
commit a06f9cf2c4
85 changed files with 3762 additions and 2072 deletions
@@ -1,8 +1,9 @@
export const DELIVERY_CARRIER_CODES = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] as const
import { DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS } from '@shared/constants/delivery-carrier'
export const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
/** Варианты для формы чекаута (код → подпись). */
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
{ code: 'RUSSIAN_POST', label: 'Почта России' },
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
+3 -13
View File
@@ -1,19 +1,9 @@
export const ORDER_STATUSES = [
'DRAFT',
'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'READY_FOR_PICKUP',
'DONE',
'CANCELLED',
] as const
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
export type OrderStatus = (typeof ORDER_STATUSES)[number]
/** Следующие статусы, доступные админу (смена через PATCH). */
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
switch (status) {
case 'DRAFT':
+3 -2
View File
@@ -1,5 +1,6 @@
/** Должно совпадать с `getProductImageMaxFileBytes()` на сервере (по умолчанию 20 МБ). */
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT } from '@shared/constants/upload-limits'
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
export function formatAdminImageMaxSizeHint(): string {
return `${Math.round(ADMIN_UPLOAD_IMAGE_MAX_BYTES / (1024 * 1024))} МБ`
@@ -0,0 +1,10 @@
import { createEffect, createEvent, createStore } from 'effector'
export function createErrorStore<Fx extends ReturnType<typeof createEffect>>(fx: Fx) {
const reset = createEvent()
const $error = createStore<unknown | null>(null)
.on(fx.failData, (_, e) => e)
.reset([fx, reset])
return { $error, reset }
}
+26
View File
@@ -0,0 +1,26 @@
const TOKEN_KEY = 'craftshop_auth_token'
export function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
export function persistToken(token: string | null): void {
try {
if (!token) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, token)
} catch {
// ignore
}
}
export function removeStoredToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore
}
}
+47 -60
View File
@@ -1,47 +1,32 @@
import { createEffect, createEvent, createStore, sample } from 'effector'
import { apiClient } from '@/shared/api/client'
import { createErrorStore } from '@/shared/lib/create-error-store'
import { persistToken } from '@/shared/lib/persist-token'
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
const TOKEN_KEY = 'craftshop_auth_token'
export const tokenSet = createEvent<string | null>()
export const logout = createEvent()
// ----- Token persistence -----
const persistTokenFx = createEffect<string | null, void>({
handler: (token) => persistToken(token),
})
export const $token = createStore<string | null>(null)
.on(tokenSet, (_, t) => t)
.reset(logout)
sample({
clock: $token,
target: persistTokenFx,
})
// ----- User -----
export const $user = createStore<AuthUser | null>(null).reset(logout)
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
await apiClient.post('me/change-email/request-code', { newEmail })
})
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
return data.user
})
export type UpdateProfileParams = { name: string | null; phone?: string | null }
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
return data.user
})
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
.reset(requestEmailChangeCodeFx, logout)
export const $verifyEmailChangeError = createStore<unknown | null>(null)
.on(verifyEmailChangeFx.failData, (_, e) => e)
.reset(verifyEmailChangeFx, logout)
export const $updateProfileError = createStore<unknown | null>(null)
.on(updateProfileFx.failData, (_, e) => e)
.reset(updateProfileFx, logout)
export const meFx = createEffect(async (token: string) => {
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
headers: { Authorization: `Bearer ${token}` },
@@ -60,37 +45,39 @@ sample({
target: $user,
})
// ----- Email change -----
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
await apiClient.post('me/change-email/request-code', { newEmail })
})
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
return data.user
})
// ----- Profile update -----
export type UpdateProfileParams = { name: string | null; phone?: string | null }
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
return data.user
})
// ----- Error stores -----
export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error
export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error
export const $updateProfileError = createErrorStore(updateProfileFx).$error
// ----- Re-exports -----
export { readStoredToken } from '@/shared/lib/persist-token'
// ----- Sync user from profile/email changes -----
sample({
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
target: $user,
})
let tokenPersistInitialized = false
$token.watch((t) => {
try {
if (!tokenPersistInitialized) {
tokenPersistInitialized = true
return
}
if (!t) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, t)
} catch {
// ignore
}
})
logout.watch(() => {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore
}
})
export function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography'
type Props = {
open: boolean
onClose: () => void
title: ReactNode
children: ReactNode
actions?: ReactNode
loading?: boolean
error?: string | null
maxWidth?: 'xs' | 'sm' | 'md' | 'lg'
}
export function AdminDialog({ open, onClose, title, children, actions, loading, error, maxWidth = 'sm' }: Props) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth={maxWidth}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
{loading && <Typography>Загрузка</Typography>}
{error && <Alert severity="error">{error}</Alert>}
{!loading && !error && children}
</DialogContent>
<DialogActions>
{actions}
<Button onClick={onClose}>Закрыть</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,61 @@
import type { ReactNode } from 'react'
import Alert from '@mui/material/Alert'
import Skeleton from '@mui/material/Skeleton'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
export type AdminTableColumn = {
key: string
label: string
align?: 'left' | 'right' | 'center'
}
type Props = {
columns: AdminTableColumn[]
children: ReactNode
loading?: boolean
error?: string | null
skeletonRows?: number
}
export function AdminTable({ columns, children, loading, error, skeletonRows = 3 }: Props) {
return (
<Table size="small">
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.key} align={col.align}>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{error && (
<TableRow>
<TableCell colSpan={columns.length}>
<Alert severity="error">{error}</Alert>
</TableCell>
</TableRow>
)}
{loading && !error && (
<>
{Array.from({ length: skeletonRows }).map((_, i) => (
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={col.key}>
<Skeleton />
</TableCell>
))}
</TableRow>
))}
</>
)}
{!loading && !error && children}
</TableBody>
</Table>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { AdminTable } from './AdminTable'
export type { Column as AdminTableColumn } from './AdminTable'