From 97537a8717ec1002eff9d1e2ce88a9f0123f9190 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Sun, 10 May 2026 13:50:44 +0500 Subject: [PATCH] base commit --- client/src/app/App.tsx | 2 + client/src/app/layout/AppHeader.tsx | 1 + client/src/app/layout/MainLayout.tsx | 15 +++- .../src/entities/order/api/admin-order-api.ts | 2 + client/src/entities/order/api/order-api.ts | 4 + client/src/pages/about/index.ts | 1 + client/src/pages/about/ui/AboutPage.tsx | 87 +++++++++++++++++++ .../pages/admin-orders/ui/AdminOrdersPage.tsx | 21 ++--- client/src/pages/auth/ui/AuthPage.tsx | 16 +--- client/src/pages/checkout/ui/CheckoutPage.tsx | 41 ++++++++- client/src/pages/home/ui/HomePage.tsx | 7 +- .../src/pages/me/ui/sections/MessagesPage.tsx | 33 +++---- .../pages/me/ui/sections/OrderDetailPage.tsx | 81 +++++++++-------- client/src/pages/product/ui/ProductPage.tsx | 2 +- client/src/shared/constants/pickup-point.ts | 6 ++ client/src/shared/ui/ChatMessageBubble.tsx | 29 +++++++ .../src/shared/ui/RichTextMessageContent.tsx | 4 +- .../src/shared/ui/RichTextMessageEditor.tsx | 2 +- .../migration.sql | 26 ++++++ server/prisma/schema.prisma | 2 + server/src/routes/api/admin-orders.js | 1 + server/src/routes/auth.js | 24 ++++- 22 files changed, 307 insertions(+), 100 deletions(-) create mode 100644 client/src/pages/about/index.ts create mode 100644 client/src/pages/about/ui/AboutPage.tsx create mode 100644 client/src/shared/constants/pickup-point.ts create mode 100644 client/src/shared/ui/ChatMessageBubble.tsx create mode 100644 server/prisma/migrations/20260510084617_order_payment_method/migration.sql diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index cc98017..1da7e99 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AppProviders } from '@/app/providers/AppProviders' +import { AboutPage } from '@/pages/about' import { AdminLayoutPage } from '@/pages/admin-layout' import { AuthCallbackPage, AuthPage } from '@/pages/auth' import { CartPage } from '@/pages/cart' @@ -22,6 +23,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 48ce77b..e038252 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -39,6 +39,7 @@ type NavItem = { label: string; to: string } const navItems: NavItem[] = [ { label: 'Каталог', to: '/' }, + { label: 'О нас', to: '/about' }, { label: 'О покупке', to: '/info' }, ] diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index e2fdfe7..c70be3b 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -34,7 +34,7 @@ export function MainLayout({ children }: PropsWithChildren) { - + Магазин @@ -50,10 +50,13 @@ export function MainLayout({ children }: PropsWithChildren) { - + Покупателям + + О нас и самовывоз + Личный кабинет @@ -63,7 +66,7 @@ export function MainLayout({ children }: PropsWithChildren) { - + Контакты @@ -86,7 +89,11 @@ export function MainLayout({ children }: PropsWithChildren) { - + © {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины. diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index e65cfdb..b1c1487 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -4,6 +4,7 @@ export type AdminOrderListItem = { id: string status: string deliveryType: 'delivery' | 'pickup' + paymentMethod?: 'online' | 'on_pickup' totalCents: number currency: string createdAt: string @@ -24,6 +25,7 @@ export type AdminOrderDetailResponse = { id: string status: string deliveryType: 'delivery' | 'pickup' + paymentMethod?: 'online' | 'on_pickup' itemsSubtotalCents: number deliveryFeeCents: number totalCents: number diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts index 0e3a431..2f299dc 100644 --- a/client/src/entities/order/api/order-api.ts +++ b/client/src/entities/order/api/order-api.ts @@ -12,11 +12,14 @@ export type OrderListItem = { export type OrderListResponse = { items: OrderListItem[] } +export type OrderPaymentMethod = 'online' | 'on_pickup' + export type OrderDetailResponse = { item: { id: string status: string deliveryType: 'delivery' | 'pickup' + paymentMethod?: OrderPaymentMethod itemsSubtotalCents: number deliveryFeeCents: number totalCents: number @@ -43,6 +46,7 @@ export type OrderDetailResponse = { export async function createOrder(body: { deliveryType: 'delivery' | 'pickup' + paymentMethod?: OrderPaymentMethod addressId?: string | null comment?: string | null }): Promise<{ orderId: string }> { diff --git a/client/src/pages/about/index.ts b/client/src/pages/about/index.ts new file mode 100644 index 0000000..cf4057f --- /dev/null +++ b/client/src/pages/about/index.ts @@ -0,0 +1 @@ +export { AboutPage } from './ui/AboutPage' diff --git a/client/src/pages/about/ui/AboutPage.tsx b/client/src/pages/about/ui/AboutPage.tsx new file mode 100644 index 0000000..55e3de8 --- /dev/null +++ b/client/src/pages/about/ui/AboutPage.tsx @@ -0,0 +1,87 @@ +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import * as maplibregl from 'maplibre-gl' +import Map, { Marker } from 'react-map-gl/maplibre' +import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point' + +const rasterStyle = { + version: 8 as const, + sources: { + osm: { + type: 'raster' as const, + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }], +} + +export function AboutPage() { + const { lat, lng } = PICKUP_COORDINATES + return ( + + + О нас + + + Магазин изделий ручной работы. Мы отвечаем за качество и сроки изготовления всего, что вы видите в каталоге. + + + + + + Контакты и самовывоз + + + Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже): + + {PICKUP_ADDRESS_FULL} + + Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче. + + + + + + + + + + + + + ) +} diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index ef924fc..bdfb808 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -30,6 +30,7 @@ import { formatPriceRub } from '@/shared/lib/format-price' import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' @@ -207,6 +208,10 @@ export function AdminOrdersPage() { #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} {formatPriceRub(detail.totalCents)} + + Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} + {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} + @@ -240,25 +245,13 @@ export function AdminOrdersPage() { {detail.messages.map((m) => ( - + {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '} {new Date(m.createdAt).toLocaleString()} - + ))} {detail.messages.length === 0 && Нет сообщений.} diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index e1cc1e0..11687da 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' @@ -11,7 +10,6 @@ import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' import { apiClient } from '@/shared/api/client' -import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url' import { $user, tokenSet } from '@/shared/model/auth' type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } } @@ -93,21 +91,9 @@ export function AuthPage() { )} - Быстрый вход - - - - - - или по email - - Вариант 1: Email + код + Email + код - - )} - {order.status === 'PAYMENT_VERIFICATION' && ( - - Оплата отправлена на проверку. Мы проверим поступление и обновим статус. - - )} - {!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && ( + {payOnPickup ? ( - На этом этапе действий по оплате в этом блоке не требуется. + Оплата при получении на точке самовывоза (наличные или карта — по договорённости). + ) : ( + <> + {order.status === 'PENDING_PAYMENT' && ( + <> + + Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты». + + + + )} + {order.status === 'PAYMENT_VERIFICATION' && ( + + Оплата отправлена на проверку. Мы проверим поступление и обновим статус. + + )} + {!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && ( + + На этом этапе действий по оплате в этом блоке не требуется. + + )} + )} @@ -316,24 +341,12 @@ export function OrderDetailPage() { {order.messages.map((m) => ( - + {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} - + ))} {order.messages.length === 0 && Пока сообщений нет.} diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index 452a8c4..c181eed 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -155,7 +155,7 @@ export function ProductPage() { {formatPriceRub(p.priceCents)} - {!isAdmin && } + {!isAdmin && !(p.inStock && p.quantity === 0) ? : null} {!p.inStock && ( diff --git a/client/src/shared/constants/pickup-point.ts b/client/src/shared/constants/pickup-point.ts new file mode 100644 index 0000000..28181bc --- /dev/null +++ b/client/src/shared/constants/pickup-point.ts @@ -0,0 +1,6 @@ +/** Точка самовывоза (координаты центра участка ул. Мира по данным OSM/Nominatim). */ +export const PICKUP_COORDINATES = { lat: 58.0994284, lng: 57.803296 } + +/** Полная строка адреса для текстовых блоков. */ +export const PICKUP_ADDRESS_FULL = + '34, улица Мира, Лысьва, Лысьвенский муниципальный округ, Пермский край, Приволжский федеральный округ, 618909, Россия' diff --git a/client/src/shared/ui/ChatMessageBubble.tsx b/client/src/shared/ui/ChatMessageBubble.tsx new file mode 100644 index 0000000..0fa56e5 --- /dev/null +++ b/client/src/shared/ui/ChatMessageBubble.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' +import Box from '@mui/material/Box' +import { alpha } from '@mui/material/styles' + +type Author = 'admin' | 'user' + +export function ChatMessageBubble(props: { authorType: Author; children: ReactNode }) { + const { authorType, children } = props + return ( + + authorType === 'admin' + ? alpha(theme.palette.grey[500], theme.palette.mode === 'dark' ? 0.28 : 0.14) + : alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.28 : 0.1), + }} + > + {children} + + ) +} diff --git a/client/src/shared/ui/RichTextMessageContent.tsx b/client/src/shared/ui/RichTextMessageContent.tsx index be0caf0..0d636d9 100644 --- a/client/src/shared/ui/RichTextMessageContent.tsx +++ b/client/src/shared/ui/RichTextMessageContent.tsx @@ -21,16 +21,18 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess if (!editor) return const normalizedValue = value.trim() ? value : '

' if (editor.getHTML() === normalizedValue) return - editor.commands.setContent(normalizedValue, false) + editor.commands.setContent(normalizedValue, { emitUpdate: false }) }, [editor, value]) return (

' if (editor.getHTML() === normalizedValue) return - editor.commands.setContent(normalizedValue, false) + editor.commands.setContent(normalizedValue, { emitUpdate: false }) }, [editor, value]) return ( diff --git a/server/prisma/migrations/20260510084617_order_payment_method/migration.sql b/server/prisma/migrations/20260510084617_order_payment_method/migration.sql new file mode 100644 index 0000000..a4a854c --- /dev/null +++ b/server/prisma/migrations/20260510084617_order_payment_method/migration.sql @@ -0,0 +1,26 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Order" ( + "id" TEXT NOT NULL PRIMARY KEY, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "deliveryType" TEXT NOT NULL DEFAULT 'delivery', + "paymentMethod" TEXT NOT NULL DEFAULT 'online', + "itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0, + "deliveryFeeCents" INTEGER NOT NULL DEFAULT 0, + "totalCents" INTEGER NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'RUB', + "addressSnapshotJson" TEXT, + "comment" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId" FROM "Order"; +DROP TABLE "Order"; +ALTER TABLE "new_Order" RENAME TO "Order"; +CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt"); +CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index fc0d904..e08a014 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -109,6 +109,8 @@ model Order { status String @default("DRAFT") /// 'delivery' | 'pickup' deliveryType String @default("delivery") + /// 'online' | 'on_pickup' — способ расчёта для заказа + paymentMethod String @default("online") itemsSubtotalCents Int @default(0) deliveryFeeCents Int @default(0) totalCents Int @default(0) diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index da7224e..1a35d70 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -57,6 +57,7 @@ export async function registerAdminOrderRoutes(fastify) { id: o.id, status: o.status, deliveryType: o.deliveryType, + paymentMethod: o.paymentMethod, totalCents: o.totalCents, currency: o.currency, createdAt: o.createdAt, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c97fc3d..24dc158 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -420,10 +420,23 @@ export async function registerAuthRoutes(fastify) { const commentRaw = request.body?.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + const paymentMethodRaw = request.body?.paymentMethod + const paymentMethod = + paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === '' + ? 'online' + : String(paymentMethodRaw).trim() + if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') { + return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' }) + } + if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) } + if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { + return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' }) + } + let address = null if (deliveryType === 'delivery') { if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) @@ -472,6 +485,8 @@ export async function registerAuthRoutes(fastify) { lng: address.lng, }) + const initialStatus = paymentMethod === 'on_pickup' ? 'IN_PROGRESS' : 'PENDING_PAYMENT' + let created try { created = await prisma.$transaction(async (tx) => { @@ -491,8 +506,9 @@ export async function registerAuthRoutes(fastify) { const order = await tx.order.create({ data: { userId, - status: 'PENDING_PAYMENT', + status: initialStatus, deliveryType, + paymentMethod, itemsSubtotalCents, deliveryFeeCents, totalCents, @@ -678,6 +694,12 @@ export async function registerAuthRoutes(fastify) { const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + const paymentMethod = order.paymentMethod ?? 'online' + if (paymentMethod === 'on_pickup') { + return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' }) + } + let nextStatus = order.status if (order.status === 'DRAFT') { await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })