Compare commits

..

431 Commits

Author SHA1 Message Date
Kirill 93b1624191 вы 2026-06-11 16:50:47 +05:00
Kirill fdffc9bdf6 add diaposine 2026-06-10 12:57:16 +05:00
Kirill 464c858970 add diaposine 2026-06-10 12:56:42 +05:00
Kirill 5f18274b2c add diaposine 2026-06-03 19:44:39 +05:00
Kirill 11c1e012d5 add diaposine 2026-06-03 18:52:44 +05:00
Kirill 01f5b90c99 add diaposine 2026-06-03 13:52:39 +05:00
Kirill cc6ceac3a0 add diaposine 2026-06-03 13:16:57 +05:00
Kirill b7faf2d891 add diaposine 2026-06-02 11:08:25 +05:00
Kirill 41f8e3ef42 add diaposine 2026-06-02 11:00:29 +05:00
Kirill 6c341045b8 add diaposine 2026-06-02 09:48:39 +05:00
Kirill 29d240424b ываыв 2026-06-01 16:32:24 +05:00
Kirill ca8ed0da62 ываыв 2026-05-28 22:46:15 +05:00
Kirill a036802e03 Merge branch 'туц_fixes' 2026-05-28 22:42:43 +05:00
Kirill 4ddf95dadf ываыв 2026-05-28 22:42:33 +05:00
Kirill ef3cc25dd4 asdasd 2026-05-28 22:33:36 +05:00
Kirill 5b9c2f4c46 asdasd 2026-05-28 22:08:49 +05:00
Kirill 21aa19b5d1 Merge branch 'refak3' 2026-05-28 21:53:53 +05:00
Kirill c6d509b1a6 asdasd 2026-05-28 21:53:48 +05:00
Kirill 12222ddf43 asdasd 2026-05-28 21:53:35 +05:00
Kirill f34c05095a ываыв 2026-05-28 21:46:17 +05:00
Kirill ba375aee12 ываыв 2026-05-28 21:29:30 +05:00
Kirill 79f48bd1d0 ываыв 2026-05-28 21:26:48 +05:00
Kirill 0a276657fb Merge branch 'feature/admin-orders-ux-improvements' 2026-05-28 21:22:14 +05:00
Kirill ef68cc3ce8 Merge branch 'final_fixes' 2026-05-28 21:22:10 +05:00
Kirill 8adda7e463 пара 2026-05-28 21:22:04 +05:00
Kirill 9c1c824f10 шдод олрол 2026-05-28 21:20:40 +05:00
Kirill 7000fbffa7 ывав 2026-05-28 21:20:35 +05:00
Kirill 966731d3e1 ывав 2026-05-28 12:10:55 +05:00
Kirill f15f331de5 ывав 2026-05-28 12:10:24 +05:00
Kirill 2889cd9545 ывав 2026-05-28 11:48:35 +05:00
Kirill ae36123fa7 ыввы 2026-05-28 11:47:53 +05:00
Kirill 3e00c402b5 ыввы 2026-05-27 23:43:22 +05:00
Kirill 9aea74db96 fix: use theme callback in sx to force Alert icon/text color to primary.main; add backdrop-filter + shadow 2026-05-27 23:35:22 +05:00
Kirill 8e1f977d43 fix: match action button + close icon color to primary (price color); add shadow + backdrop blur to Alert 2026-05-27 23:29:25 +05:00
Kirill 39c9808eaa fix: use native Snackbar positioning for NotificationStack instead of custom Stack wrapper 2026-05-27 23:25:02 +05:00
Kirill 0bfff541a3 fix: make Alert colors scheme-aware using palette tokens for berry/ocean/craft/forest compatibility 2026-05-27 23:23:55 +05:00
Kirill 8133e0cf63 fix: restore CartSnackbar styling and 'Перейти в корзину' action button in NotificationStack 2026-05-27 23:16:03 +05:00
Kirill 599b66503d style: fix import order and prettier formatting 2026-05-27 22:07:01 +05:00
Kirill b0b2872cf8 refactor: remove duplicate order status labels, use ORDER_STATUS_DATA as single source 2026-05-27 21:54:53 +05:00
Kirill ed424a3b0b refactor: extract useCartQuery hook 2026-05-27 21:54:49 +05:00
Kirill 45627206c0 refactor: extract findUserOrder helper 2026-05-27 21:47:16 +05:00
Kirill 24b3b4063d refactor: extract validateGalleryImages helper 2026-05-27 21:41:33 +05:00
Kirill 6615d97203 refactor: apply asyncHandler to all route handlers 2026-05-27 21:39:18 +05:00
Kirill 36e75ab24a feat: add asyncHandler decorator for route error handling 2026-05-27 21:33:56 +05:00
Kirill d254c3c813 fix: adapt useMutationWithToast to react-query v5 callback signatures 2026-05-27 21:30:42 +05:00
Kirill 7642edc154 fix: use getApiErrorMessage in CheckoutPage for user-friendly error display 2026-05-27 21:30:27 +05:00
Kirill 4ca04878cb feat: add useMutationWithToast wrapper 2026-05-27 21:24:09 +05:00
Kirill 39286f0fe0 feat: improve getApiErrorMessage with user-friendly messages 2026-05-27 21:21:48 +05:00
Kirill 9502a0c550 fix: wrap dismissNotification with useUnit in NotificationStack for scope isolation; fix test selectors 2026-05-27 21:19:08 +05:00
Kirill 30bb25c416 feat: migrate CartSnackbar to global notification store 2026-05-27 21:10:01 +05:00
Kirill e173977daf feat: integrate NotificationStack into App 2026-05-27 21:08:15 +05:00
Kirill e2d4423e2e feat: add notification store and NotificationStack component 2026-05-27 21:07:55 +05:00
Kirill f6414adf2f fix: add error logging to empty catch blocks 2026-05-27 20:56:08 +05:00
Kirill 8f3bd7aa3b ыввы 2026-05-27 18:34:45 +05:00
Kirill b392884503 ыввы 2026-05-27 18:29:41 +05:00
Kirill 5071d70746 ыввы 2026-05-27 17:50:39 +05:00
Kirill 4eb93aac54 ыввы 2026-05-27 16:52:51 +05:00
Kirill 73f85f7439 Merge branch 'perf2' 2026-05-27 16:31:54 +05:00
Kirill dae23599af ыввы 2026-05-26 13:03:20 +05:00
Kirill e93fc7a972 ыввы 2026-05-26 12:52:47 +05:00
Kirill c01070cb09 ыввы 2026-05-26 12:30:09 +05:00
Kirill e092299a11 ыввы 2026-05-26 12:10:38 +05:00
Kirill 4b8b86e1b8 Merge branch 'perfomance' 2026-05-26 10:32:46 +05:00
Kirill 82a0beb97c ыввы 2026-05-25 23:20:33 +05:00
Kirill e5e1e01c7e ыввы 2026-05-25 23:06:41 +05:00
Kirill 09c5e0cd50 ыввы 2026-05-25 21:14:19 +05:00
Kirill af582a813f fix: prevent adjacent slide peeking in ProductCard Swiper 2026-05-25 19:25:43 +05:00
Kirill 0576cc1251 ыввы 2026-05-25 19:22:53 +05:00
Kirill 69813b0fd0 fix: guard against destroyed editor in RichTextMessageEditor 2026-05-25 19:15:32 +05:00
Kirill 849e96511e fix: center icons in Alert and CartSnackbar components 2026-05-25 19:12:06 +05:00
Kirill a40c68141e fix: move isDark declaration outside palette IIFE 2026-05-25 19:10:00 +05:00
Kirill 9784ac3cb2 design: style Snackbar and Alert with minimalist warm monochrome palette 2026-05-25 19:03:50 +05:00
Kirill f24308bb56 design: upgrade typography, shadows, spacing, empty states, 404 page, focus rings, noise overlay 2026-05-25 18:57:25 +05:00
Kirill 0771209c5d ыввы 2026-05-25 18:46:48 +05:00
Kirill bd8722cfcb fix: move CartSnackbar inside BrowserRouter in App.tsx 2026-05-25 18:27:26 +05:00
Kirill bedf98245b test: add AddToCartButton and ToggleCartIcon integration tests, fix timer cleanup 2026-05-25 18:14:27 +05:00
Kirill c9e3917129 feat: fire cartAdded event in ToggleCartIcon add mutation 2026-05-25 17:56:45 +05:00
Kirill d87abb6425 feat: fire cartAdded event in AddToCartButton 2026-05-25 17:48:55 +05:00
Kirill 45aee539a2 fix: move CartSnackbar inside theme provider 2026-05-25 17:45:53 +05:00
Kirill cb3b2e64ad feat: mount CartSnackbar in AppProviders 2026-05-25 17:41:44 +05:00
Kirill fc2a04dafc fix: use vi.mock for navigate test, fix import order in CartSnackbar 2026-05-25 17:35:44 +05:00
Kirill b17e571772 fix: remove redundant timer, add navigation verification to CartSnackbar tests 2026-05-25 17:29:54 +05:00
Kirill ddae0e8583 feat: add CartSnackbar component with tests 2026-05-25 17:26:54 +05:00
Kirill 4aba164c78 test: add cart-notifications store tests 2026-05-25 17:18:31 +05:00
Kirill 9f5c2f8637 feat: add cart notification effector store 2026-05-25 17:09:45 +05:00
Kirill 1cfabab4c6 docs: cart added snackbar design spec 2026-05-25 17:04:57 +05:00
Kirill af6b249248 ыввы 2026-05-25 16:54:37 +05:00
Kirill 74fe39829d ыввы 2026-05-25 16:28:03 +05:00
Kirill e4012d8133 ыввы 2026-05-24 20:36:31 +05:00
Kirill 56ad07cb56 chore: remove unused favicon.svg 2026-05-24 20:32:59 +05:00
Kirill ca302c4e2d feat: replace header icon and favicon with brand image 2026-05-24 20:29:17 +05:00
Kirill 9ce3375088 ыввы 2026-05-24 19:55:43 +05:00
Kirill c46e19f95e fix: close React.memo wrapping in 3 components 2026-05-24 19:53:39 +05:00
Kirill c9342f833b perf: add React.memo to hot-path components 2026-05-24 19:44:50 +05:00
Kirill d7e355dc78 perf: lazy-load all public routes 2026-05-24 19:43:02 +05:00
Kirill 0dd5f8b8ff perf: dynamic import dicebear avatar styles 2026-05-24 19:42:17 +05:00
Kirill 8a4fd53bc4 perf: lazy-load TipTap via RichText components 2026-05-24 19:39:40 +05:00
Kirill c2b685c0dc perf: code-split maplibre-gl in AboutPage 2026-05-24 19:38:40 +05:00
Kirill 3b10d8764d perf: code-split maplibre-gl in AddressMapPicker 2026-05-24 19:37:43 +05:00
Kirill 261233442e docs: add frontend performance optimization design spec 2026-05-24 19:23:38 +05:00
Kirill bc417375b5 Merge branch 'tests' 2026-05-24 19:11:25 +05:00
Kirill 39699d77ce ыввы 2026-05-24 19:11:18 +05:00
Kirill ae83e2ae5f ывав 2026-05-24 19:10:53 +05:00
Kirill 4b89c42a72 пва 2026-05-24 17:07:46 +05:00
Kirill 80e3cd1b30 fix: allow null comment in server validation, remove debug logging 2026-05-24 17:06:07 +05:00
Kirill 8474ee0f80 debug: add console logging to handleStatusClick and updateMutation onError 2026-05-24 17:04:31 +05:00
Kirill c81b7b1e2d fix: remove autoFocus and fix prettier formatting in error dialog 2026-05-24 17:00:34 +05:00
Kirill 5516b3309c feat: add 3-state status and error comment dialog to test checklist 2026-05-24 16:57:16 +05:00
Kirill 19dd5f213c feat: add comment to test-checklist API client types and function 2026-05-24 16:53:59 +05:00
Kirill 42c83b5d4e feat: support comment field in test-checklist API 2026-05-24 16:52:38 +05:00
Kirill 5ef3861e84 feat: add comment field to ChecklistResult for error descriptions 2026-05-24 16:50:51 +05:00
Kirill b757f18bfb docs: update test checklist spec — 3-state status + error comments 2026-05-24 16:47:51 +05:00
Kirill 6f29da65cc fix: resolve TS errors — readonly array type and Stack sx props 2026-05-24 16:36:10 +05:00
Kirill 649ebb0256 style: fix prettier formatting 2026-05-24 16:35:11 +05:00
Kirill 6e046e0e35 fix: remove autoFocus to satisfy jsx-a11y rule 2026-05-24 16:34:35 +05:00
Kirill af8107ebe0 feat: add test-checklist to admin navigation and routing 2026-05-24 16:31:31 +05:00
Kirill 93c098a088 fix: add error state handling for checklist query 2026-05-24 16:30:25 +05:00
Kirill 69f7e4f9e8 feat: add admin test checklist page 2026-05-24 16:27:14 +05:00
Kirill 873d98eb1e fix: use type aliases and named response type for consistency 2026-05-24 16:24:57 +05:00
Kirill 5b88a3c9a5 feat: add test-checklist API client functions 2026-05-24 16:22:24 +05:00
Kirill 83ae974017 fix: align test-checklist error handling with project convention 2026-05-24 16:21:33 +05:00
Kirill dc1c004a82 feat: add admin test-checklist API routes 2026-05-24 16:18:19 +05:00
Kirill 82f11e0492 feat: add test checklist items shared constants 2026-05-24 16:14:26 +05:00
Kirill 53f02e1782 feat: add ChecklistResult model for manual test checklist 2026-05-24 16:10:12 +05:00
Kirill e0e94f4439 docs: add admin test checklist design spec 2026-05-24 16:03:37 +05:00
Kirill f6c9d1e5a9 Merge branch 'final' 2026-05-24 15:49:25 +05:00
Kirill 96f06c79b4 пва 2026-05-24 15:48:33 +05:00
Kirill 88fedd675a пва 2026-05-24 15:10:24 +05:00
Kirill 8d4ff3ef62 Merge branch 'site-fixes' 2026-05-24 14:23:09 +05:00
Kirill e9b4edc792 пва 2026-05-24 14:22:58 +05:00
Kirill 971a08997b пва 2026-05-24 14:03:13 +05:00
Kirill c2c4099fd7 пва 2026-05-24 13:59:14 +05:00
Kirill 2fe426b70a пва 2026-05-24 13:43:23 +05:00
Kirill 75841342c6 пва 2026-05-24 12:45:54 +05:00
Kirill 755e9dcad3 пва 2026-05-24 12:44:39 +05:00
Kirill 1041af32e5 feat: show synthetic email warning in NotificationsPage 2026-05-24 12:34:23 +05:00
Kirill e52588686a feat: show synthetic email warning in AuthMethodsSection 2026-05-24 12:29:46 +05:00
Kirill 1e98720751 feat: add isSyntheticEmail utility for detecting synthetic OAuth emails 2026-05-24 12:24:42 +05:00
Kirill dba8e902bf docs: add synthetic email warning design spec 2026-05-24 12:18:27 +05:00
Kirill ff2271ecb1 пва 2026-05-24 12:03:21 +05:00
Kirill d0d7eab77e пва 2026-05-23 18:47:35 +05:00
Kirill bd9bdc0352 пва 2026-05-23 17:31:07 +05:00
Kirill d660663b72 feat: intercept 403 HTML responses and show gate page 2026-05-23 11:44:10 +05:00
Kirill 4bcced4e08 пва 2026-05-23 11:17:29 +05:00
Kirill 347fcac6a7 docs: add SITE_ACCESS_IPS to .env.example 2026-05-23 11:13:36 +05:00
Kirill eee200ae04 Register ip-gate plugin before auth 2026-05-23 11:12:52 +05:00
Kirill 8001d7d32c fix: handle undefined SITE_ACCESS_IPS restore, add build403Html('') test 2026-05-23 11:12:00 +05:00
Kirill fd720572e7 fix: export build403Html, add unit test for undefined IP fallback 2026-05-23 11:09:21 +05:00
Kirill 5fdf49658f test: add ip-gate plugin tests 2026-05-23 11:06:57 +05:00
Kirill 51cc5832c3 fix: normalize IPv6-mapped IPv4 addresses in IP gate 2026-05-23 11:04:32 +05:00
Kirill 8ed2f0e9ba fix: simplify title and status message in 403 page 2026-05-23 11:01:37 +05:00
Kirill e22f084940 feat: add IP gate plugin with SITE_ACCESS_IPS env var support 2026-05-23 11:00:02 +05:00
Kirill 54022d72ff docs: add IP-gate access control implementation plan 2026-05-23 10:58:36 +05:00
Kirill 1b9cc8ac57 docs: add IP-gate access control spec 2026-05-23 10:56:08 +05:00
Kirill b58ad6cc45 Merge branch 'new-fixes' 2026-05-23 10:32:50 +05:00
Kirill 7e5ed9cefa пва 2026-05-22 23:44:48 +05:00
Kirill bb78782b39 пва 2026-05-22 23:22:29 +05:00
Kirill e85ebc203c Merge branch 'sitefixws' 2026-05-22 23:09:44 +05:00
Kirill d60270336e пва 2026-05-22 23:03:03 +05:00
Kirill 13cc1fa2b8 docs: add VK no-email fix design spec 2026-05-22 22:51:03 +05:00
Kirill f0af519ec1 fix: VK OAuth uses short UUID state + in-memory PKCE store instead of JWT 2026-05-22 21:02:33 +05:00
Kirill 9d7e7949b9 feat: migrate VK OAuth to VK ID flow with PKCE 2026-05-22 20:54:48 +05:00
Kirill bead725036 fix: strip trailing slash from SERVER_PUBLIC_URL to prevent double-slash in OAuth redirect_uri 2026-05-22 20:31:02 +05:00
Kirill caa9b926e3 пва 2026-05-22 20:20:08 +05:00
Kirill 0f2ac862de feat: add WB_PVZ (Wildberries pickup) delivery carrier 2026-05-22 19:51:34 +05:00
Kirill cc94917c5f feat: add email, phone, VK contacts to About page 2026-05-22 19:43:31 +05:00
Kirill 3d0dbdd0a5 пва 2026-05-22 19:41:47 +05:00
Kirill 5644a2ede2 feat: replace footer VK inline icon with SVG logo 2026-05-22 19:41:04 +05:00
Kirill 20e4b1e0ab feat: latin-only slugs, server-side avatar generation, remove unused User fields 2026-05-22 19:32:30 +05:00
Kirill 02c7d7ba36 fix: review avatar uses authorId instead of displayName, show reviews for hidden products 2026-05-22 19:14:22 +05:00
Kirill a96944328d style: prettier format SseProvider test 2026-05-22 18:46:42 +05:00
Kirill 3b627e8e2f feat: mount SseProvider, remove polling from layouts 2026-05-22 18:45:00 +05:00
Kirill 86523cda71 feat: add SseProvider — SSE to ReactQuery bridge with tests 2026-05-22 18:43:02 +05:00
Kirill a5e875292d test: add SseProvider tests (TDD red) 2026-05-22 18:40:57 +05:00
Kirill a84045a68d feat: add EventSource factory for SSE 2026-05-22 18:39:10 +05:00
Kirill 4381121f25 feat: register SSE routes in server 2026-05-22 18:38:48 +05:00
Kirill e2a04d04a3 fix: add safeWrite guard and error handler for SSE socket 2026-05-22 18:37:55 +05:00
Kirill 5127d4a093 feat: add SSE route with EventBus bridge and tests 2026-05-22 18:33:49 +05:00
Kirill 55dc58cff8 fix: gate ADMIN_EMAIL test with explicit skip 2026-05-22 18:25:22 +05:00
Kirill 6b89f42269 test: add SSE route tests (TDD red) 2026-05-22 18:23:11 +05:00
Kirill 3212d6c185 docs: SSE realtime implementation plan 2026-05-22 18:18:54 +05:00
Kirill 76c8564e77 docs: SSE realtime design spec 2026-05-22 18:08:24 +05:00
Kirill 8fb01126b8 пва 2026-05-22 17:47:22 +05:00
Kirill bc85fa8e84 пва 2026-05-22 17:44:42 +05:00
Kirill b38b24f158 fix(auth): add missing onRegisterChange prop to test 2026-05-22 16:04:50 +05:00
Kirill c903db439d fix(auth): enable register tab switching 2026-05-22 15:59:38 +05:00
Kirill 237106f2a4 fix(client): parse error message properly in ProfileSection 2026-05-22 15:41:50 +05:00
Kirill 955368d898 Merge branch 'refac2' 2026-05-22 15:36:48 +05:00
Kirill f39d4e82ff пва 2026-05-22 15:36:39 +05:00
Kirill 2b5c7fff5e fix(server): remove duplicate registerAuthRoutes call 2026-05-22 15:31:35 +05:00
Kirill b3b539b6fb fix(api): register auth routes
Add missing registerAuthRoutes call in registerApiRoutes to enable
POST /api/auth/request-code, /verify-code, /register, /login,
/forgot-password, /reset-password, and PATCH /api/me/profile routes
2026-05-22 15:21:55 +05:00
Kirill 49f24d7482 split auth.js into focused modules (Task 3)
- auth-session.js: GET /api/me, GET /api/me/auth-methods
- auth-password.js: POST /api/me/password, POST /api/me/change-password
- auth-oauth.js: DELETE /api/me/oauth/:provider
- auth.js: kept only /api/auth/* routes + /api/me/profile
- api.js: registers new auth route modules
- tests split to separate files per module
2026-05-22 15:19:30 +05:00
Kirill be9a9bad8e fix(Task 2): add error handling and sync state with user
- AuthMethodsSection: show Alert on fetchAuthMethodsFx failure
- AvatarSection: sync selectedStyle with user.avatarStyle via Select key
- ProfileSection: reset form defaultValues when user.displayName changes
2026-05-22 15:15:50 +05:00
Kirill fa276eb7f3 fix(settings): use $updateProfileError and changePasswordFx per spec 2026-05-22 15:10:20 +05:00
Kirill e273c29c36 refactor(SettingsPage): split into ProfileSection, AvatarSection, AuthMethodsSection
- Extract ProfileSection (45 lines): display name form with save button
- Extract AvatarSection (114 lines): avatar preview, style selector, generate/save/cancel
- Extract AuthMethodsSection (204 lines): auth methods list, set/change password forms
- Rewrite SettingsPage as composer (41 lines): composes 3 sections with dividers
- Add tests for all 3 sections
2026-05-22 15:04:49 +05:00
Kirill 03e60e46f3 fix(auth): defer setState in OAuth error effect to avoid cascading renders 2026-05-22 14:50:38 +05:00
Kirill b1530ef705 fix(auth): add forgot password flow and fix OAuth URL clearing 2026-05-22 14:47:06 +05:00
Kirill 68bbbf8895 refactor(auth): extract AuthPasswordForm and AuthCodeForm to features
- Create auth-password feature with login/register form
- Create auth-code feature with email+code verification form
- Extract getApiErrorMessage to shared lib
- Simplify AuthPage to pure UI composer with tabs
- Update tests for new component structure
- All 40 tests passing
2026-05-22 14:36:19 +05:00
Kirill da13ce2848 Merge branch 'autorizayion' 2026-05-22 14:22:28 +05:00
Kirill c9fb9cc8fc test commit 2026-05-22 14:22:24 +05:00
Kirill d79d02d5d1 refactor: remove email change functionality 2026-05-22 14:20:11 +05:00
Kirill ad43ff98b6 feat: add password change and reset via email code 2026-05-22 14:12:29 +05:00
Kirill 22282c5f4e fix: accept token as query param in authenticate, pass token to oauth link URL 2026-05-22 13:52:48 +05:00
Kirill d51266446f fix(client): remove global borderWidth change on outlined button hover 2026-05-22 13:49:31 +05:00
Kirill b7c11fbce6 test commit 2026-05-22 13:46:37 +05:00
Kirill f02c615dd9 fix(client): remove hover shift on pill tabs and OAuth buttons 2026-05-22 13:45:05 +05:00
Kirill cf61a5c44f fix(client): remove OAuth tab, show VK/Yandex always with separator, fix pill hover offset 2026-05-22 13:41:52 +05:00
Kirill e468625cfc chore: fix type errors, move textAlign/fontWeight to sx 2026-05-22 13:28:45 +05:00
Kirill 9696a4dcc3 feat(client): redesign auth page with minimal style, BearLogo, pill buttons 2026-05-22 13:24:35 +05:00
Kirill eb30640b49 feat: load Outfit font from static files 2026-05-22 13:18:21 +05:00
Kirill 669b9aa45d test commit 2026-05-22 12:51:41 +05:00
Kirill b2ccc2a256 chore: fix lint issues, remove unused hasAvatar 2026-05-22 12:27:20 +05:00
Kirill 5651403d2e test(client): add auth page tab tests 2026-05-22 12:21:50 +05:00
Kirill 39d6a1604c fix(client): remove avatarType and OAuth avatar from admin settings 2026-05-22 12:18:45 +05:00
Kirill 6d23aafcc1 feat(client): add auth methods section to settings page 2026-05-22 12:16:58 +05:00
Kirill afc763c522 feat(client): auth page with 3 tabs (password/code/oauth) 2026-05-22 12:11:36 +05:00
Kirill be65f2330e refactor(client): remove avatarType, add auth effects, simplify UserAvatar 2026-05-22 12:08:41 +05:00
Kirill 6bedf0b28a test(server): add password auth and account methods tests 2026-05-22 11:57:11 +05:00
Kirill abb14a49e0 feat(server): add auth-methods, set-password, unlink-oauth endpoints 2026-05-22 11:47:46 +05:00
Kirill c9fa05b7bf feat(server): add oauth link routes for account binding 2026-05-22 11:45:12 +05:00
Kirill 5f180fffaf refactor(server): oauth only email, remove profile requests, support account linking state 2026-05-22 11:41:40 +05:00
Kirill bb7b40ac45 fix(server): remove all avatarType references after DB column drop 2026-05-22 11:36:11 +05:00
Kirill c3e4f5bdd2 feat(server): add POST /api/auth/register and /api/auth/login
- Add register endpoint with email/password validation, bcrypt hashing
- Add login endpoint with rate limiting per IP (5 attempts/min)
- Add helper functions: validatePassword, hashPassword, comparePassword, isAdminEmail
- Add checkLoginRateLimit for brute-force protection
- Add bcrypt dependency
- Remove avatarType column from User (migration)
2026-05-22 11:26:00 +05:00
Kirill 924d7b7b77 test commit 2026-05-21 21:58:49 +05:00
Kirill 4fa4a91ddc feat: avatars in /me/messages chat 2026-05-21 21:55:10 +05:00
Kirill f6729210db feat: public admin avatar endpoint, real admin avatar in user chat 2026-05-21 21:50:07 +05:00
Kirill 367ea1e501 test commit 2026-05-21 21:39:36 +05:00
Kirill b7895b3fe1 Merge branch 'fixes' 2026-05-21 21:17:29 +05:00
Kirill 44c95502f8 test commit 2026-05-21 21:17:16 +05:00
Kirill c5775c7f5d test commit 2026-05-21 21:17:06 +05:00
Kirill e09fe7211a fix: type-only import for UpdateProfileParams 2026-05-21 21:12:29 +05:00
Kirill 57da755ea1 feat: real user avatars in reviews, conditional product link 2026-05-21 21:10:49 +05:00
Kirill 7e7bade80c feat: avatars in order messages 2026-05-21 21:05:22 +05:00
Kirill d69647ffe3 fix: out of stock chip z-index, PersonIcon for unauthenticated users 2026-05-21 21:00:26 +05:00
Kirill 7a9e44bc5c fix: rename name to displayName in AdminUser type and page 2026-05-21 20:58:50 +05:00
Kirill 2751332356 feat: avatar column in admin users table 2026-05-21 20:52:43 +05:00
Kirill d1e4cc67aa feat: admin avatar in header with settings link 2026-05-21 20:45:23 +05:00
Kirill 52290e162e fix: use mutation variables in onSuccess, fix null displayName handling 2026-05-21 20:42:59 +05:00
Kirill 0dfa428931 feat: add admin settings page for display name and avatar editing 2026-05-21 20:28:35 +05:00
Kirill 37be5eef08 docs: avatar and display fixes implementation plan 2026-05-21 20:18:32 +05:00
Kirill ff7a4b6bba docs: avatar and display fixes design spec 2026-05-21 20:09:22 +05:00
Kirill 098c4c2b54 Merge branch 'refack2' 2026-05-21 14:33:14 +05:00
Kirill d056399b3b test commit 2026-05-21 14:32:45 +05:00
Kirill 47124a01a7 test commit 2026-05-21 14:22:03 +05:00
Kirill 058fa26e12 test commit 2026-05-21 13:39:45 +05:00
Kirill a176955521 test commit 2026-05-21 12:18:36 +05:00
Kirill 76cd19e3ab test commit 2026-05-21 12:04:07 +05:00
Kirill 7117978800 Merge branch 'payd' 2026-05-21 12:03:23 +05:00
Kirill 41b95d7122 test commit 2026-05-21 12:03:07 +05:00
Kirill 1837b36b14 test commit 2026-05-21 12:02:29 +05:00
Kirill ae6f86041a fix: trustProxy for webhook IP validation, filter expired payments, remove dead code 2026-05-20 19:40:23 +05:00
Kirill 3177413acd chore: fix prettier formatting 2026-05-20 19:33:13 +05:00
Kirill faac332138 feat: implement yookassa redirect payment flow on client 2026-05-20 19:28:46 +05:00
Kirill 698293e2f1 feat: remove old manual payment dialog and api method 2026-05-20 19:22:51 +05:00
Kirill dcf601d4a2 feat: add yookassa webhook endpoint 2026-05-20 19:19:48 +05:00
Kirill 317b910710 fix: email validation, conditional order update, improved tests for payment routes 2026-05-20 19:12:46 +05:00
Kirill 7d0854a294 fix: use correct notification event name in payment route 2026-05-20 19:00:39 +05:00
Kirill 8d45155b54 feat: rewrite payment route for yookassa redirect flow 2026-05-20 18:53:21 +05:00
Kirill abadbbd4c4 fix: add retry to getPayment, normalize return, env validation, webhook/builder tests 2026-05-20 18:11:14 +05:00
Kirill a3556367c6 fix: correct retryable check in yookassa fetchWithRetry 2026-05-20 18:04:07 +05:00
Kirill 3879e4b388 feat: add yookassa API client library with tests 2026-05-20 17:59:35 +05:00
Kirill e2cea63af0 feat: add yookassa env vars to .env.example 2026-05-20 17:54:54 +05:00
Kirill dad644190a fix: remove redundant index on yookassaPaymentId 2026-05-20 17:54:01 +05:00
Kirill 7bba78b4c0 feat: add Payment model for yookassa integration 2026-05-20 17:49:14 +05:00
Kirill 585c565b7b docs: add yookassa payment integration implementation plan 2026-05-20 17:45:20 +05:00
Kirill fc7bc43c9f docs: add yookassa payment integration design spec 2026-05-20 17:33:08 +05:00
Kirill 98631e1d1a Merge branch 'autorization' 2026-05-20 17:01:43 +05:00
Kirill b06ba64365 test commit 2026-05-20 12:07:22 +05:00
Kirill af5376d0e1 fix: rename name→displayName in remaining Prisma select clauses 2026-05-20 11:31:24 +05:00
Kirill c32d5e6aff fix: use sx for justifyContent in OAuthButtons, fix import order in test 2026-05-20 11:14:36 +05:00
Kirill 1873681fa6 test: OAuthButtons component 2026-05-20 11:12:13 +05:00
Kirill bf22aaf917 test: OAuth user model fields 2026-05-20 11:10:18 +05:00
Kirill 76d215e4dc docs: add Yandex OAuth scopes to .env.example 2026-05-20 11:08:18 +05:00
Kirill e8f5bba9bf feat: add OAuth buttons to AuthPage 2026-05-20 11:07:18 +05:00
Kirill ec2c70e3ae feat: add OAuthButtons component 2026-05-20 11:04:25 +05:00
Kirill 54f5ec78c3 feat: add oauth providers config 2026-05-20 11:02:07 +05:00
Kirill 00b74e56d7 refactor: rename name→displayName across client 2026-05-20 11:00:28 +05:00
Kirill 8d9c250eb7 refactor: rename name→displayName in AuthUser type 2026-05-20 10:57:11 +05:00
Kirill 6fde248dc5 feat: enrich Yandex OAuth with firstName/lastName/gender/avatar 2026-05-20 10:55:37 +05:00
Kirill d2d2f721cd feat: enrich VK OAuth with firstName/lastName/gender/avatar 2026-05-20 10:53:58 +05:00
Kirill 32a4406cb8 refactor: rename name→displayName in review files 2026-05-20 10:51:48 +05:00
Kirill cc7e46b447 refactor: rename name→displayName in admin-users 2026-05-20 10:50:38 +05:00
Kirill ce49f75100 feat: use displayName in mapUserForClient and profile update 2026-05-20 10:46:31 +05:00
Kirill 36880c298c feat: rename User.name→displayName, add firstName/lastName/gender/avatar 2026-05-20 10:39:01 +05:00
Kirill d931545a2e docs: yandex+vk oauth implementation plan 2026-05-20 10:35:43 +05:00
Kirill 01bd9f8968 docs: yandex+vk oauth design spec 2026-05-20 10:26:45 +05:00
Kirill 8257a19292 Merge branch 'instruktion' 2026-05-19 15:33:02 +05:00
Kirill cb4661dc13 test commit 2026-05-19 15:32:45 +05:00
Kirill 0b01b61e48 fix: use MUI v9 slots API for StepLabel stepIcon 2026-05-19 15:18:42 +05:00
Kirill 17b683f131 feat: remove InfoPageBlock model from Prisma schema 2026-05-19 15:14:38 +05:00
Kirill 57275514bf feat: remove server info-page routes 2026-05-19 14:56:37 +05:00
Kirill 348ffd940c feat: remove info page from admin navigation and routes 2026-05-19 14:56:08 +05:00
Kirill 5eadbd0d0e feat: remove info entity (admin CRUD layer) 2026-05-19 14:55:28 +05:00
Kirill dbe36ce6fd feat: remove admin info page CRUD 2026-05-19 14:55:13 +05:00
Kirill 777ba6ec15 feat: rewrite InfoPage as static container with section components 2026-05-19 14:53:42 +05:00
Kirill e7cc518d7f fix: prettier formatting and step key in info sections 2026-05-19 14:52:11 +05:00
Kirill f01ede6ee9 feat: add ReturnsSection with return and warranty blocks 2026-05-19 14:44:23 +05:00
Kirill 2ffa11be50 feat: add DeliverySection with pickup, courier, and postal cards 2026-05-19 14:44:08 +05:00
Kirill 22ac9e381d feat: add PaymentSection with card and cash methods 2026-05-19 14:43:58 +05:00
Kirill 4952ed6371 feat: add HowToOrderSection with purchase step stepper 2026-05-19 14:43:26 +05:00
Kirill 7250875a5c Merge branch 'refack' 2026-05-19 11:26:17 +05:00
Kirill 5adbe9baa7 test commit 2026-05-19 11:25:23 +05:00
Kirill f8867f6457 Merge branch 'redisign' 2026-05-19 10:33:42 +05:00
Kirill 3972133155 test commit 2026-05-19 10:31:59 +05:00
Kirill c25b4f97f2 test commit 2026-05-19 10:30:05 +05:00
Kirill 9999b28d49 test commit 2026-05-19 09:00:26 +05:00
Kirill 0ee9e76a30 test commit 2026-05-18 21:25:02 +05:00
Kirill d0b3c97803 feat: improve notifications - fix auth code tg duplicate, double order notify, add PAID label, expand text, add deliveryFeeAdjusted event 2026-05-18 14:48:54 +05:00
Kirill 2f67c37502 test commit 2026-05-18 13:54:05 +05:00
Kirill 7421384161 Merge branch 'notification' 2026-05-18 12:19:44 +05:00
Kirill 29f3aba4ae test commit 2026-05-18 12:19:33 +05:00
Kirill 6912008a2c test: add notification preferences tests 2026-05-18 12:06:29 +05:00
Kirill 6054ef4c06 feat: add admin notification settings page 2026-05-18 11:55:45 +05:00
Kirill dfec821545 feat: add user notification settings page 2026-05-18 11:51:41 +05:00
Kirill ea0d6bdb91 feat: add notification API client functions 2026-05-18 11:47:31 +05:00
Kirill 912724082e test: add notification preferences tests 2026-05-18 11:45:51 +05:00
Kirill 1d36f6a31b feat: create admin notification settings on bootstrap 2026-05-18 11:40:24 +05:00
Kirill 84cdccaa17 feat: emit notification events from existing routes 2026-05-18 11:39:02 +05:00
Kirill e73a0ae09a feat: wire up notification system in server 2026-05-18 11:36:19 +05:00
Kirill 3f83a9be8e feat: add notification queue with retry worker 2026-05-18 11:33:06 +05:00
Kirill 4a424b68a2 feat: add notification preferences resolver 2026-05-18 11:31:16 +05:00
Kirill e0a045d5df feat: add Telegram notification channel 2026-05-18 11:29:58 +05:00
Kirill 8f3d1ae5ef feat: add email notification channel 2026-05-18 11:28:46 +05:00
Kirill 79c85b0a88 feat: add Telegram message templates 2026-05-18 11:26:52 +05:00
Kirill 86f8569840 feat: add email templates for notifications 2026-05-18 11:25:46 +05:00
Kirill 09ada62daf feat: add notification event bus 2026-05-18 11:24:01 +05:00
Kirill dcbcb42acd feat: add notification event type constants 2026-05-18 11:23:12 +05:00
Kirill 4816d098da feat: add notification system database models 2026-05-18 11:20:53 +05:00
Kirill d18546c45a feat(client): slider picker shows only resized images
chore(server): remove unused gallery.js
2026-05-17 18:20:57 +05:00
Kirill f0365d0b98 feat(client): remove direct upload from product form, filter gallery to resized 2026-05-17 18:17:27 +05:00
Kirill 35dee985f7 feat(client): complete AdminGalleryPage with new upload and resize UI 2026-05-17 18:15:07 +05:00
Kirill 5411f8ae24 feat(client): add resize button and status badge to GalleryGrid 2026-05-17 18:09:12 +05:00
Kirill cf6b5da4fc feat(client): add isResized type, uploadGalleryImages, resizeGalleryImage API 2026-05-17 18:03:32 +05:00
Kirill 02172f7995 test(server): add gallery resize test, adapt upload tests 2026-05-17 18:00:15 +05:00
Kirill 5637bb7db9 feat(server): remove old /admin/uploads, validate isResized on product endpoints 2026-05-17 17:54:13 +05:00
Kirill 9226bcc571 feat(server): add POST /api/admin/gallery/:id/resize endpoint 2026-05-17 17:50:27 +05:00
Kirill 248f8766aa feat(server): add POST /api/admin/gallery/upload endpoint 2026-05-17 17:43:47 +05:00
Kirill c8281a39e5 feat(db): add isResized to GalleryImage 2026-05-17 17:39:44 +05:00
Kirill f36439de38 Merge branch 'FIX-ORDER' 2026-05-15 22:09:38 +05:00
Kirill 347089b173 test commit 2026-05-15 22:09:24 +05:00
Kirill f855568687 refactor: simplify order status model — remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION
- Add deliveryFeeLocked field to Order model
- Remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION statuses (11→8)
- 3 order paths: delivery+online (locked→unlocked→paid), pickup+online (unlocked→paid), pickup+on_pickup (direct to in_progress)
- Update checkout to use PENDING_PAYMENT + deliveryFeeLocked
- Update payment flow to stay in PENDING_PAYMENT until admin confirms
- Update admin UI to use deliveryFeeLocked instead of status check
- Update client payment UI with new deliveryFeeLocked logic
2026-05-15 21:55:14 +05:00
Kirill 2db6258b33 test commit 2026-05-15 21:14:07 +05:00
Kirill 518879dabb test commit 2026-05-15 20:44:17 +05:00
Kirill 0a3aa2b891 update goods 2026-05-15 20:40:09 +05:00
Kirill d499aeb735 chore: remove plans and uploads from git tracking 2026-05-15 20:32:52 +05:00
Kirill 7e231dbdd8 feat: use WebP original for full-screen product image viewer 2026-05-15 20:27:45 +05:00
Kirill 56bdcc0351 feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl 2026-05-15 20:21:29 +05:00
Kirill dc448d6538 feat: improve error messages for user upload size validation 2026-05-15 20:19:00 +05:00
Kirill d73d88d034 feat: enable eager image processing for admin uploads 2026-05-15 20:16:07 +05:00
Kirill b22c3f312c feat: add eager mode to persistMultipartImages 2026-05-15 20:10:45 +05:00
Kirill 3e5e495b9f fix: reuse sharp instance and UPLOADS_DIR constant in eager processing 2026-05-15 19:52:16 +05:00
Kirill 5de2694a14 feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp) 2026-05-15 19:48:15 +05:00
Kirill 8b79c01985 fix: downgrade sharp to 0.32.6 for older CPU compatibility, add /uploads-resized/ to nginx config 2026-05-15 19:06:22 +05:00
Kirill 1739f52ad3 update goods 2026-05-15 15:49:04 +05:00
Kirill be48606ae3 update goods 2026-05-15 14:58:47 +05:00
Kirill 551c9b027c update goods 2026-05-15 14:43:26 +05:00
Kirill 48dc9de456 update goods 2026-05-15 14:24:25 +05:00
Kirill 25901ae224 update goods 2026-05-15 14:23:54 +05:00
Kirill ed475be289 fix: apply lint fixes and fix vite manualChunks for Vite 8 compatibility 2026-05-15 13:43:51 +05:00
Kirill d8798de49a test: add server tests for image resize library 2026-05-15 13:32:34 +05:00
Kirill 50eb427f5c perf: use OptimizedImage in admin pages 2026-05-15 13:30:27 +05:00
Kirill 9c238bd542 perf: use OptimizedImage in ProductPage 2026-05-15 13:30:05 +05:00
Kirill 35ddb17247 perf: use OptimizedImage in CatalogSlider 2026-05-15 13:29:58 +05:00
Kirill 8965eb6dad perf: use OptimizedImage in ReviewsBlock 2026-05-15 13:29:50 +05:00
Kirill b4f436cbdf perf: use OptimizedImage in GalleryGrid 2026-05-15 13:29:47 +05:00
Kirill b52471ec97 perf: use OptimizedImage in ProductCard 2026-05-15 13:29:45 +05:00
Kirill 5856a9eaf6 feat: add OptimizedImage component with AVIF/WebP srcset 2026-05-15 13:27:22 +05:00
Kirill 301f3eee5c perf: lazy load admin and me routes with Suspense 2026-05-15 13:27:15 +05:00
Kirill 5246a4e52e perf: add Vite manualChunks for vendor code splitting 2026-05-15 13:26:24 +05:00
Kirill ec1d2c4b1a seo: add meta tags for description, OG, theme-color, canonical 2026-05-15 13:26:20 +05:00
Kirill db5e3c4d52 chore: add uploads/.cache/ to gitignore 2026-05-15 13:26:11 +05:00
Kirill 0bef02bc6d feat: add uploads-resized route with sharp resizing and cache headers 2026-05-15 13:24:16 +05:00
Kirill c37743eee6 feat: separate review images into /uploads/reviews/ subdir 2026-05-15 13:24:14 +05:00
Kirill 66b0558a42 feat: add image resize library with sharp 2026-05-15 13:23:57 +05:00
Kirill 906dc61d0a docs: add lighthouse optimization implementation plan 2026-05-15 13:19:15 +05:00
Kirill 78fc1d4d96 docs: add lighthouse optimization design spec 2026-05-15 13:12:42 +05:00
Kirill 1b7ec703ee Merge branch 'update-goods' 2026-05-15 12:55:32 +05:00
Kirill 10ffa21c66 update goods 2026-05-15 12:55:23 +05:00
Kirill 89d605adf4 update goods 2026-05-15 12:50:39 +05:00
Kirill c5634deb51 test refactor 2026-05-14 22:40:35 +05:00
Kirill 1de7649276 Merge branch 'style2' 2026-05-14 22:03:10 +05:00
Kirill 298c4f63d5 test refactor 2026-05-14 22:02:56 +05:00
Kirill 4b027c643a fix: scheme icon gap 1, lighter/larger bg 2026-05-14 22:01:21 +05:00
Kirill 001742a856 fix: icon gap, lighter scheme bg, filled cart icon for inCart 2026-05-14 21:55:54 +05:00
Kirill 8a39eb9ce7 fix: icon spacing, scheme bg, drawer layout, filter margin, cart icon states 2026-05-14 21:46:41 +05:00
Kirill d5075813a2 fix: lint and type errors in ToggleCartIcon, AdminLayout, ProductFilters, use-product-filters 2026-05-14 21:36:00 +05:00
Kirill 8632601490 feat: UI style refresh — Lucide icons, theme, slider, filters, buttons, VK 2026-05-14 21:25:11 +05:00
Kirill 3b85f2cb57 Merge branch 'style' 2026-05-14 20:41:25 +05:00
Kirill a577cec9a9 fix: root-only server, no deploy user 2026-05-14 20:40:34 +05:00
Kirill 434859b606 docs: add SERVER_SETUP.md, update README with new deploy section 2026-05-14 20:33:59 +05:00
Kirill 5dfcbeaa23 feat: add deploy-auto.sh with git diff detection; update gitignore 2026-05-14 20:32:51 +05:00
Kirill 4ffafc41c7 chore: remove obsolete deploy scripts and docs 2026-05-14 20:31:39 +05:00
@kirill.komarov ce9883f8c9 test refactor 2026-05-14 19:54:45 +05:00
@kirill.komarov 8165f75a78 Merge branch 'refactor2' 2026-05-13 22:40:45 +05:00
kirill.komarov c6b542bd95 fix: TypeScript errors — NominatimItem import, unknown ReactNode, unused deliveryType, effect generics, export name 2026-05-13 22:16:00 +05:00
@kirill.komarov a06f9cf2c4 Merge branch 'refactor' 2026-05-13 22:07:46 +05:00
@kirill.komarov 3c9797af4a deploy 2026-05-13 12:48:17 +05:00
@kirill.komarov c424a9cbef deploy 2026-05-13 12:37:45 +05:00
@kirill.komarov 40483679de deploy 2026-05-13 12:33:46 +05:00
@kirill.komarov c6228dfaab deploy 2026-05-13 12:17:08 +05:00
@kirill.komarov 83bbf9d263 deploy 2026-05-12 22:01:01 +05:00
@kirill.komarov 519c647f65 deploy 2026-05-12 21:53:11 +05:00
@kirill.komarov 57fa4adf08 deploy 2026-05-12 21:37:39 +05:00
@kirill.komarov 33e387d05c deploy 2026-05-11 21:00:56 +05:00
@kirill.komarov 130c12a1d3 deploy 2026-05-11 20:42:26 +05:00
@kirill.komarov 212484d062 deploy 2026-05-11 20:20:36 +05:00
@kirill.komarov 7a92991cff deploy 2026-05-11 20:15:01 +05:00
@kirill.komarov 4eda6d0f81 deploy 2026-05-11 15:14:35 +05:00
@kirill.komarov 20096c1eec deploy 2026-05-10 17:38:04 +05:00
@kirill.komarov df4435dd67 deploy 2026-05-10 17:26:22 +05:00
@kirill.komarov 517cd23a55 deploy 2026-05-10 17:15:56 +05:00
@kirill.komarov e67d8bdc0a deploy 2026-05-10 16:49:55 +05:00
@kirill.komarov f56d6a79fb base commit 2026-05-10 14:38:32 +05:00
@kirill.komarov 5ddde15fd3 base commit 2026-05-10 14:28:35 +05:00
@kirill.komarov 1e376caecc base commit 2026-05-10 14:05:24 +05:00
@kirill.komarov 97537a8717 base commit 2026-05-10 13:50:44 +05:00
@kirill.komarov 6c07488964 Merge branch 'figma_make' 2026-05-10 13:27:01 +05:00
@kirill.komarov ebe1ede25c base commit 2026-05-04 12:34:01 +05:00
@kirill.komarov 6885e39017 base commit 2026-05-03 20:30:21 +05:00
@kirill.komarov fe10f25b8c base commit 2026-05-03 19:57:12 +05:00
@kirill.komarov 9139a24093 base commit 2026-04-30 22:34:55 +05:00
@kirill.komarov 123d86091d base commit 2026-04-29 20:23:30 +05:00
@kirill.komarov f26223091a base commit 2026-04-29 19:29:24 +05:00
@kirill.komarov bfc9661d22 base commit 2026-04-29 19:14:34 +05:00
@kirill.komarov c1773e5c57 base commit 2026-04-29 18:39:40 +05:00
@kirill.komarov 326521c9e6 base commit 2026-04-29 18:34:25 +05:00
@kirill.komarov f6b6959268 base commit 2026-04-29 17:32:21 +05:00
@kirill.komarov 3f7fdb1e15 base commit 2026-04-28 22:15:12 +05:00
@kirill.komarov d40edf97e7 base commit 2026-04-28 21:47:43 +05:00
@kirill.komarov 2148fd7a12 base commit 2026-04-28 21:36:30 +05:00
@kirill.komarov 55480d4aa5 init project 2026-04-28 11:02:08 +05:00
501 changed files with 91940 additions and 40 deletions
+30
View File
@@ -0,0 +1,30 @@
---
description: Основной промт/правила для проекта craftshop (client+server, FSD, ESLint/Prettier)
alwaysApply: true
---
# Craftshop: постоянный промт для агента
## Контекст и цель
- Проект: магазин изделий ручной работы (витрина + админка для загрузки/редактирования данных).
- ОС: Windows. Отвечать пользователю **по-русски**.
## Стек и структура
- **Frontend**: Vite + React + TypeScript, axios, @tanstack/react-query, MUI.
- **Архитектура фронта**: **FSD** (`app/pages/widgets/features/entities/shared`), alias `@` → `client/src`.
- **Backend**: Node.js + Fastify + Prisma + SQLite.
- Данные управляются через фронтенд‑админку; доступ к админ‑API проверяется серверным `verifyAdmin` (JWT пользователя + совпадение `request.user.email` с `ADMIN_EMAIL`).
## Правила работы с кодом
- Всегда придерживаться **FSD границ**: нижние слои не импортируют верхние.
- Для запросов: использовать `apiClient` (axios) и **React Query** (queryKey стабильные, invalidate после мутаций).
- UI: использовать компоненты **MUI**, без “самописного” дизайна там, где есть готовые компоненты.
- Не добавлять зависимости без необходимости; если добавляешь — ставь последние стабильные версии и обновляй README при изменении запуска/скриптов.
## Качество и запуск
- После изменений на фронте: `client` → `npm run lint` и при необходимости `npm run lint:fix`, затем `npm run format:check`.
- Форматирование: Prettier конфиги лежат в `client/.prettierrc.json`, `.prettierignore`, `.editorconfig`.
## Бэкенд соглашения
- Не ломать публичные роуты `/api/categories`, `/api/products`.
- Админ‑роуты должны возвращать понятные ошибки (400/401/404/409) и валидировать входные данные.
@@ -0,0 +1,12 @@
---
description: Актуальные требования к Vite proxy для локальной разработки
globs: client/vite.config.ts
alwaysApply: false
---
# Frontend Dev Server Proxy
- В `client/vite.config.ts` должны проксироваться и API, и загрузки файлов.
- Обязательные прокси:
- `'/api' -> 'http://127.0.0.1:3333'`
- `'/uploads' -> 'http://127.0.0.1:3333'`
+13
View File
@@ -0,0 +1,13 @@
---
description: Правила использования RichTextMessageContent (TipTap) на фронтенде
globs: client/src/**/*.tsx
alwaysApply: false
---
# Frontend Rich Text (TipTap)
- Для отображения rich text использовать общий компонент `shared/ui/RichTextMessageContent`.
- Не дублировать стили ProseMirror локально на страницах и в виджетах без необходимости.
- Для контекста отзывов передавать `tone="review"`.
- Для переписок по заказам передавать `tone="chat"`.
- `tone="default"` использовать только в нейтральных/общих сценариях.
-10
View File
@@ -1,10 +0,0 @@
name: Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: bash /opt/deploy-shop.sh server main
+10 -1
View File
@@ -5,5 +5,14 @@ dist
scripts/deploy.env
server/prisma/dev.db
server/prisma/dev.db-journal
server/uploads/
.deployed-commit
# Image resize cache
uploads/.cache/
# Server uploads directory (images)
server/uploads/
# Plans and design docs
.agents
server/prisma/prisma/dev.db
@@ -0,0 +1,95 @@
# Spec: Image Processing Refactor
## Context
Current image handling uses on-demand resize via `/uploads-resized/` route. Admin uploads save originals as-is (jpg/png/webp), and resize happens on first request. User uploads (reviews, 2MB limit) also use on-demand resize.
## Goals
1. **User images (reviews, ≤2MB):** Improve size error messages to be user-friendly
2. **Admin images (products, ≤20MB):** Eager processing at upload time
- Generate all resize widths (320, 640, 1024, 1600) in AVIF + WebP
- Convert original to WebP (delete source file)
- Full-screen viewer shows original in WebP (no width limit)
- Thumbnails use resized versions from cache
## Architecture
### Server Changes
#### 1. `server/src/lib/upload-images.js`
- Add `eager` parameter to `persistMultipartImages`
- When `eager: true`, after saving each file:
1. Call `generateAllSizes(uuid, subdir, fullPath)` — generates all sizes from original
2. Call `convertOriginalToWebp(uuid, subdir)` — converts original to WebP, deletes source
3. Update URL to use `.webp` extension (replace original extension)
#### 2. `server/src/lib/image-resize.js`
- Add `generateAllSizes(uuid, subdir, originalPath)`:
- For each width in [320, 640, 1024, 1600]:
- Generate AVIF and WebP in `.cache/<subdir>/`
- Uses original file path (before conversion to WebP)
- Add `convertOriginalToWebp(uuid, subdir)`:
- Find original file (jpg/png)
- Convert to WebP (quality 80) at same location with `.webp` extension
- Delete original jpg/png file
- Return new `.webp` path
#### 3. `server/src/routes/api/admin-products.js`
- Pass `eager: true` to `persistMultipartImages`
#### 4. `server/src/routes/api/public-reviews.js`
- Improve error message for file too large (413)
### Client Changes
#### 1. `client/src/entities/product/api/product-api.ts`
- Add pre-upload size check for review images
- Clear error message: "Файл «<name>» слишком большой (максимум 2 МБ)"
#### 2. `client/src/shared/ui/OptimizedImage.tsx`
- Update `buildSrcSet` to use cached AVIF/WebP directly
- Full-screen viewer: use original `.webp` URL (no `?w=`)
- Remove fallback to original format for upload URLs
#### 3. `client/src/features/product-review/ui/ReviewDialog.tsx`
- Show user-friendly error message for oversized files
## Data Flow
### Admin Upload (Eager)
1. Client sends FormData to `POST /api/admin/uploads`
2. Server saves original (e.g., `uuid.jpg`)
3. Server generates all sizes in `.cache/` from original
4. Server converts original to WebP (`uuid.webp`), deletes `uuid.jpg`
5. Returns URLs with `.webp` extension (e.g., `/uploads/<uuid>.webp`)
6. Client displays using OptimizedImage with srcset from cache
### User Upload (Reviews)
1. Client validates file size ≤2MB before upload
2. Server validates and saves original
3. On-demand resize still works (existing flow)
4. Clear error messages at both client and server
## Error Handling
### User Upload Size Error
- **Client:** Pre-upload check with message "Файл «<name>» слишком большой (максимум 2 МБ)"
- **Server:** 413 with "Файл слишком большой (максимум 2 МБ)"
### Admin Upload Processing Error
- If sharp fails: return 500 with "Ошибка обработки изображения"
- If file not found after save: return 500 with "Внутренняя ошибка сервера"
## Testing
### Server Tests
- Test `generateAllSizes` creates all width+format combinations
- Test `convertOriginalToWebp` converts and deletes original
- Test `persistMultipartImages` with `eager: true`
- Test error messages for oversized files
### Client Tests
- Test pre-upload size validation for reviews
- Test OptimizedImage srcset generation for WebP originals
- Test error message display in ReviewDialog
@@ -0,0 +1,543 @@
# Image Processing Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling.
**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI
---
### Task 1: Add eager processing functions to image-resize.js
**Files:**
- Modify: `server/src/lib/image-resize.js`
- Test: `server/src/lib/__tests__/image-resize.test.js`
- [ ] **Step 1: Write failing tests for new functions**
Add to `server/src/lib/__tests__/image-resize.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
describe('eager image processing', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('generateAllSizes creates all width+format combinations', async () => {
// Create a test PNG image using sharp
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testImagePath)
await generateAllSizes('test-uuid', '', testImagePath)
// Check all cache files exist
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
expect(exists).toBe(true)
}
}
})
it('convertOriginalToWebp converts and deletes original', async () => {
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
.png()
.toFile(testImagePath)
const result = await convertOriginalToWebp('test-uuid2', '')
expect(result).toBe('/uploads/test-uuid2.webp')
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
expect(pngExists).toBe(false)
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
expect(webpExists).toBe(true)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined
- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp**
Add to `server/src/lib/image-resize.js` before the final `export` line:
```javascript
/**
* Generate all resize widths in AVIF + WebP for eager processing.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @param {string} originalPath - Full path to the original file
*/
export async function generateAllSizes(uuid, subdir, originalPath) {
const cacheSubdir = subdir ? subdir : ''
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
await fs.promises.mkdir(cacheDir, { recursive: true })
const sharp = (await import('sharp')).default
for (const width of VALID_WIDTHS) {
for (const format of SUPPORTED_FORMATS) {
const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
}
}
}
/**
* Convert original file to WebP and delete the source file.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @returns {string} New URL path like `/uploads/<uuid>.webp`
*/
export async function convertOriginalToWebp(uuid, subdir) {
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
// Find original file
const originalPath = await findOriginalFile(uuid, subdir)
if (!originalPath) {
throw new Error(`Original file not found for UUID: ${uuid}`)
}
const originalExt = path.extname(originalPath).toLowerCase()
const webpPath = path.join(targetDir, `${uuid}.webp`)
// Convert to WebP
const sharp = (await import('sharp')).default
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
// Delete original if it's not already WebP
if (originalExt !== '.webp') {
await fs.promises.unlink(originalPath)
}
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
```
---
### Task 2: Integrate eager processing into upload-images.js
**Files:**
- Modify: `server/src/lib/upload-images.js`
- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists)
- [ ] **Step 1: Write failing test for eager mode**
Create `server/src/lib/__tests__/upload-images.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { persistMultipartImages, uploadError } from '../upload-images.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
describe('persistMultipartImages with eager mode', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('returns WebP URLs when eager=true', async () => {
// This test verifies the function signature accepts eager parameter
// Full integration test requires mocking multipart request
// For now, test that the function doesn't throw with eager option
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
// Mock part with a small PNG buffer
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
...new Array(100).fill(0), // dummy data
])
yield {
file: true,
filename: 'test.png',
toBuffer: async () => pngHeader,
}
},
}
// Should not throw with eager option
try {
await persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
})
} catch (err) {
// If sharp is not available or PNG is invalid, that's expected in unit test
// The key is that the function accepts the eager parameter
expect(err.message).not.toContain('eager')
}
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: FAIL — `eager` parameter is not handled
- [ ] **Step 3: Modify persistMultipartImages to support eager mode**
Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`:
```javascript
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
await fs.promises.mkdir(targetDir, { recursive: true })
const urls = []
const parts = request.parts({
limits: {
fileSize: maxFileBytes,
files: maxFiles,
},
})
for await (const part of parts) {
if (!part.file) continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const uuid = crypto.randomUUID()
const fileName = `${uuid}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
if (eager) {
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
await generateAllSizes(uuid, subdir, fullPath)
finalUrl = await convertOriginalToWebp(uuid, subdir)
}
urls.push(finalUrl)
}
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
git commit -m "feat: add eager mode to persistMultipartImages"
```
---
### Task 3: Enable eager mode in admin upload route
**Files:**
- Modify: `server/src/routes/api/admin-products.js`
- [ ] **Step 1: Update admin upload route to use eager mode**
Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`:
```javascript
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
eager: true,
})
await upsertGalleryImagesByUrls(urls)
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
```
- [ ] **Step 2: Commit**
```bash
git add server/src/routes/api/admin-products.js
git commit -m "feat: enable eager image processing for admin uploads"
```
---
### Task 4: Improve user upload error messages
**Files:**
- Modify: `client/src/entities/product/api/reviews-api.ts`
- Modify: `client/src/shared/constants/upload-limits.ts`
- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx`
- [ ] **Step 1: Add client-side size validation for review images**
Add to `client/src/shared/constants/upload-limits.ts`:
```typescript
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
export function formatOtherUploadMaxSizeHint(): string {
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
}
```
- [ ] **Step 2: Add pre-upload size check in reviews-api.ts**
Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`:
```typescript
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
throw new Error(
`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`,
)
}
const fd = new FormData()
fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
return data
}
```
- [ ] **Step 3: Update ReviewDialog to show user-friendly error message**
Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`:
Replace:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
) : null}
```
With:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : null}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
git commit -m "feat: improve error messages for user upload size validation"
```
---
### Task 5: Update OptimizedImage for WebP originals
**Files:**
- Modify: `client/src/shared/ui/OptimizedImage.tsx`
- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
- [ ] **Step 1: Update parseUploadUrl to handle .webp originals**
Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
if (!match) return null
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
}
```
- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly**
Modify `buildSrcSet` and `buildFallbackSrc`:
```typescript
function buildSrcSet(src: string, widths: number[]): string | null {
const parsed = parseUploadUrl(src)
if (!parsed) return null
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
}
function buildFallbackSrc(src: string, width: number): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
}
```
- [ ] **Step 3: Add original WebP URL getter for full-screen mode**
Add to `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
/** Get the original WebP URL for full-screen display (no resize) */
export function getOriginalWebpUrl(src: string): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/ui/OptimizedImage.tsx
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
```
---
### Task 6: Update ProductPage full-screen viewer
**Files:**
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
- [ ] **Step 1: Find full-screen image viewer code**
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display**
Import and use `getOriginalWebpUrl`:
```typescript
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
```
Replace the full-screen `<img>` src with:
```typescript
getOriginalWebpUrl(imageUrl)
```
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/product/ui/ProductPage.tsx
git commit -m "feat: use WebP original for full-screen product image viewer"
```
---
### Task 7: Run full test suite and lint
- [ ] **Step 1: Run server tests**
```bash
cd server && npm test
```
- [ ] **Step 2: Run client lint and format check**
```bash
cd client && npm run lint && npm run format:check
```
- [ ] **Step 3: Run client tests**
```bash
cd client && npm test
```
- [ ] **Step 4: Run client build**
```bash
cd client && npm run build
```
- [ ] **Step 5: Commit any fixes**
```bash
git add .
git commit -m "fix: address lint and test issues"
```
@@ -0,0 +1,174 @@
# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория
**Дата:** 2026-05-15
**Статус:** На согласовании
## Цель
Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании.
## Архитектура изменений
### 1. База данных (Prisma)
**Миграция:**
- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0`
- Удалить поля `inStock` и `leadTimeDays` из модели `Product`
- Статус наличия определяется исключительно по `quantity`:
- `quantity > 0` → «В наличии»
- `quantity = 0` → «Нет в наличии»
**`server/prisma/schema.prisma`:**
```prisma
model Product {
// ... остальные поля без изменений ...
quantity Int @default(0)
// УДАЛЕНО: inStock Boolean @default(true)
// УДАЛЕНО: leadTimeDays Int?
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
categoryId String
// ...
}
```
### 2. Сервер — валидация и CRUD
**`server/src/routes/api/admin-products.js`:**
**CREATE (POST):**
- `quantity` — required, `Int >= 0` (было nullable)
- `categoryId` — required (было: при пустом → авто-назначение «Не указано»)
- Удалить валидацию `leadTimeDays` при `!inStock`
- Удалить принудительную установку `quantity = 1` для «под заказ»
- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует
**UPDATE (PATCH):**
- `quantity` — required, `Int >= 0` (было nullable)
- `categoryId` — required (было: при пустом → «Не указано»)
- Удалить логику очистки `leadTimeDays` при `inStock = true`
- Удалить принудительную установку `quantity = 1`
- Вернуть 400 при отсутствии `categoryId`
**JSON Schema:**
- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`)
- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0`
**`server/src/routes/api/public-catalog.js`:**
- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'`
- Фильтрация «в наличии» больше не нужна — все товары в каталоге
### 3. Клиент — админка (две страницы)
**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:**
**FormState:**
- Удалить `inStock: boolean` и `leadTimeDays: string`
- `quantity: string` — без nullable-семантики
**UI:**
- Удалить Switch «В наличии / Под заказ»
- Удалить TextField «Срок исполнения, дней»
- TextField «Количество»:
- Без helper «Оставьте пустым...»
- Новый helper: «0 = нет в наличии»
- Валидация: не может быть пустым, `parseInt >= 0`
- Select «Категория»:
- Удалить `<MenuItem value="">` с «Не указано»
- Валидация: не даёт сохранить без выбранной категории
- Показать ошибку при попытке сохранить без категории
**Submit-валидация:**
- Удалить проверку `leadTimeDays` при `!inStock`
- Добавить проверку: `categoryId` не пустой → blocking error
- Добавить проверку: `quantity` не пустой → blocking error
### 4. Клиент — каталог
**`client/src/entities/product/ui/ProductCard.tsx`:**
- Удалить логику `'Под заказ · {leadTimeDays} дн.'`
- Новый статус:
- `quantity > 0` → «В наличии» (зелёный)
- `quantity === 0` → «Нет в наличии» (серый/red)
**`client/src/pages/product/ui/ProductPage.tsx`:**
- Удалить chip `'Под заказ · {leadTimeDays} дн.'`
- Удалить alert `'Этот товар изготавливается под заказ...'`
- Статус определяется по `quantity`
**`client/src/pages/checkout/ui/CheckoutPage.tsx`:**
- Удалить определение made-to-order товаров в корзине
- Удалить info alert о доставке после изготовления
### 5. Клиент — фильтры
**`client/src/pages/home/lib/use-product-filters.ts`:**
- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state
- Удалить `availability` из параметров `fetchPublicProducts()`
**`client/src/pages/home/ui/ProductFilters.tsx`:**
- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'`
- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`)
### 6. Категория «Не указано» — что остаётся
| Где | Что происходит |
|---|---|
| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` |
| `server/src/index.js` | **Остаётся** — вызов при старте |
| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» |
| Админка категорий | **Остаётся** — кнопка удаления заблокирована |
| Фильтры каталога | **Скрыта** — не показывается в чипах |
| Форма товара | **Скрыта** — не выбирается в Select |
## Статус товара — новая логика
```
quantity > 0 → «В наличии» (зелёный chip/badge)
quantity = 0 → «Нет в наличии» (серый chip/badge)
```
Никаких других статусов. Поле `inStock` больше не существует.
## Файлы для изменения
### Сервер
| Файл | Изменения |
|---|---|
| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` |
| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ |
| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability |
### Клиент
| Файл | Изменения |
|---|---|
| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация |
| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация |
| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity |
| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI |
| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection |
| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» |
| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` |
## Миграция данных
```javascript
// В Prisma migration:
// 1. UPDATE Product SET quantity = 0 WHERE inStock = false
// 2. ALTER TABLE Product DROP COLUMN inStock
// 3. ALTER TABLE Product DROP COLUMN leadTimeDays
```
## Тестирование
**Сервер:**
- CREATE без categoryId → 400
- CREATE без quantity → 400
- CREATE с quantity = 0 → OK
- PATCH без categoryId → 400
- PATCH с quantity = 0 → OK
**Клиент:**
- Форма не сохраняется без категории
- Форма не сохраняется без количества
- Фильтры не содержат «Под заказ» и «Не указано»
- Карточка товара показывает «Нет в наличии» при quantity = 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
# Приведение Политики конфиденциальности и Пользовательского соглашения в соответствие с проектом
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Убрать из юридических документов упоминания несуществующих функций (аналитика, рекламные рассылки, персонализация, Яндекс.Метрика) и исправить неточности (OAuth, cookie, IP-логирование, дублирование данных оператора).
**Architecture:** 4 задачи: унификация данных оператора в shared/config, правка Политики конфиденциальности, правка Пользовательского соглашения, финальная проверка.
**Tech Stack:** TypeScript, React, MUI — изменения только в статическом JSX-тексте и shared/config.
---
## Файловая структура изменений
| Файл | Что делаем |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `client/src/shared/config/index.ts` | Добавляем `STORE_OP_NAME`, `STORE_OP_INN`, `STORE_OP_OGRN`, `STORE_OP_ADDR` |
| `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` | Импорт из config, правка пунктов 2, 3, 5, 6, 7, добавляем дату |
| `client/src/pages/terms/ui/TermsPage.tsx` | Импорт из config, правка пунктов 1, 3, 7, 8, 9, убираем противоречие 6.1 vs 2.2 |
---
### Task 1: Вынести данные оператора в shared/config
**Files:**
- Modify: `client/src/shared/config/index.ts`
- [ ] **Step 1: Добавить константы оператора в shared/config**
Вставить после строки 17 (перед `export const VK_URL`):
```ts
export const STORE_OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
export const STORE_OP_INN = "402900832341";
export const STORE_OP_OGRN = "305402922700051";
export const STORE_OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
---
### Task 2: Исправить Политику конфиденциальности
**Files:**
- Modify: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- [ ] **Step 1: Заменить импорт и локальные константы**
Заменить строки 4-10 (импорт STORE_EMAIL + локальные константы).
Было:
```ts
import { STORE_EMAIL } from "@/shared/config";
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
const SITE_URL = window.location.origin;
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
STORE_PUBLIC_SITE_URL,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
И заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Исправить раздел 2 — актуальный список собираемых данных**
Заменить `items` в секции 2.
Было:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— фамилия, имя, отчество;',
'— адрес электронной почты;',
'— номер телефона;',
'— данные файлов cookie;',
'— данные о действиях на сайте (аналитика);',
'— адрес доставки и геолокационные координаты.',
],
```
Стало:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— адрес электронной почты;',
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
'— номер телефона (указывается Пользователем добровольно при оформлении доставки);',
'— адрес доставки и геолокационные координаты (указываются Пользователем при оформлении заказа);',
'— аутентификационные данные (сессионные cookie для поддержания входа в Личный кабинет).',
],
```
- [ ] **Step 3: Исправить раздел 3 — убрать несуществующую персонализацию**
Было:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация Пользователя;',
'— оказание услуг / продажа товаров;',
'— направление уведомлений и информационных сообщений;',
'— улучшение качества работы сайта;',
'— построение персонализированных предложений и рекомендаций.',
],
```
Стало:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация и аутентификация Пользователя;',
'— оказание услуг / продажа товаров и оформление доставки;',
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
'— улучшение качества работы сайта.',
],
```
- [ ] **Step 4: Исправить раздел 5 — убрать неавтоматизированную обработку и нереалистичный срок**
Было:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
],
```
Стало:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
],
```
- [ ] **Step 5: Исправить раздел 6 — Яндекс.Метрика → ЮKassa**
Было:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
],
```
Стало:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
],
```
- [ ] **Step 6: Добавить дату обновления**
Заменить текст подзаголовка (строка 99):
```
Политика в отношении обработки персональных данных.
```
на:
```
Последнее обновление: 23 мая 2026 г.
```
- [ ] **Step 7: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 3: Исправить Пользовательское соглашение
**Files:**
- Modify: `client/src/pages/terms/ui/TermsPage.tsx`
- [ ] **Step 1: Заменить локальные константы на импорт из config**
Заменить строки 4-11.
Было:
```ts
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
Заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Дополнить раздел 1 — упомянуть OAuth и вход по коду**
В секции 1, в определении «Аутентификационные данные». Найти:
```
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
```
Заменить на:
```
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
```
- [ ] **Step 3: Убрать «рекламные» сообщения из п. 3.7**
Найти:
```
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
```
Заменить на:
```
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
```
- [ ] **Step 4: Исправить противоречие 6.1 vs 2.2 («гарантирует» vs «as is»)**
Найти в секции 6:
```
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
```
Заменить на:
```
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
```
- [ ] **Step 5: Исправить раздел 7 — указать реальных третьих лиц (ЮKassa, OSM вместо рекламы/аналитики)**
Заменить всю секцию 7.
Было:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
Стало:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей).
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
- [ ] **Step 6: Исправить раздел 8 — Cookie только для сессии, не для аналитики**
Заменить всю секцию 8.
Было:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
'8.2. Цели использования cookie:',
'— поддержка функциональности Сайта, требующей использования cookie;',
'— измерение аудитории Сайта;',
'— определение статистических предпочтений Пользователей;',
'— исследование корреляции статистических данных.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
],
},
```
Стало:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
],
},
```
- [ ] **Step 7: Исправить раздел 9.3–9.4 — актуальный перечень данных и целей**
Заменить строки 9.3 и 9.4 в секции 9.
Было:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
```
Стало:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
```
- [ ] **Step 8: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 4: Финальная проверка
**Files:** No modifications, verification only.
- [ ] **Step 1: TypeScript check**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
- [ ] **Step 2: Lint**
```bash
cd client && npm run lint
```
Expected: 0 errors (warnings OK).
- [ ] **Step 3: Сборка**
```bash
cd client && npm run build
```
Expected: успешная сборка.
- [ ] **Step 4: Format check**
```bash
cd client && npm run format:check
```
Expected: все файлы отформатированы (или отформатировать через `npm run format`).
@@ -0,0 +1,64 @@
# Юридические документы: приведение к реальности + удаление аккаунта + cookie-баннер
## Данные оператора
- **Имя:** Комарова Лариса Николаевна (самозанятый)
- **ИНН:** 402900832341 (тестовый)
- **Адрес:** 34, ул. Мира, кв. 34, Лысьва, Пермский край, 618909
- **Сайт:** https://любимыйкреатив.рф
- **ОГРН:** отсутствует (самозанятый)
## Задачи
### 1. Shared config — обновить данные оператора
Файл: `client/src/shared/config/index.ts`
- `STORE_OP_NAME`, `STORE_OP_TYPE`, `STORE_OP_INN` (тестовый), `STORE_OP_ADDR`
- Убрать `STORE_OP_OGRN`
- `STORE_PUBLIC_SITE_URL`: https://любимыйкреатив.рф
### 2. Политика конфиденциальности
Файл: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый вместо ИП, без ОГРН
- Раздел 2: только email, имя, телефон (при доставке), адрес, сессионные cookie (без ФИО, аналитики)
- Раздел 3: без персонализации
- Раздел 5: только автообработка, срок до достижения целей
- Раздел 6: Яндекс.Метрика → ЮKassa
- Раздел 7: добавить право на самоудаление (п. 7.3)
- Добавить дату обновления
- Перенести cookie-раздел из Соглашения в Политику
### 3. Пользовательское соглашение
Файл: `client/src/pages/terms/ui/TermsPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый, без ОГРН
- Раздел 1: упомянуть OAuth и вход по коду
- П. 3.7: «рекламные» → «транзакционные»
- П. 6.1: убрать противоречие с «as is»
- Раздел 7: реальные третьи лица (ЮKassa, OSM)
- Раздел 8: cookie только для сессии, не для аналитики
- П. 9.3-9.4: без IP/браузера/персонализации, только реальные данные
### 4. Cookie-баннер
Новый компонент: `client/src/shared/ui/CookieConsentBanner.tsx`
- Снизу, фиксированный, localStorage
- Текст о cookie и ссылка на Политику
- Кнопка «Понятно»
- Рендер в MainLayout перед футером
### 5. Текст согласия на формах входа/регистрации
- AuthPasswordForm, AuthCodeForm
- Под кнопкой отправки: «Нажимая «Продолжить», вы принимаете пользовательское соглашение и политику конфиденциальности»
- Ссылки на /terms и /privacy
### 6. Удаление аккаунта
**Сервер:** `DELETE /api/me` в `server/src/routes/auth.js`
- Проверка активных заказов (не DONE, не CANCELLED)
- Если нет активных — каскадное удаление
- Если есть — 400 с перечнем заказов
**Клиент:** секция в SettingsPage
- Кнопка «Удалить аккаунт» (outlined, error)
- Tooltip при активных заказах
- Диалог подтверждения
- После удаления — редирект на /
@@ -0,0 +1 @@
12063
@@ -0,0 +1 @@
12189
@@ -0,0 +1 @@
12688
@@ -0,0 +1 @@
12844
@@ -0,0 +1 @@
12996
@@ -0,0 +1 @@
13143
@@ -0,0 +1 @@
1476
@@ -0,0 +1 @@
1531
@@ -0,0 +1 @@
1616
@@ -0,0 +1 @@
1702
@@ -0,0 +1 @@
5700
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779612416287}
@@ -0,0 +1 @@
7319
+56 -21
View File
@@ -1,48 +1,83 @@
# AGENTS.md — shop-server
# AGENTS.md — shop (craftshop monorepo)
## Project structure
- `server/` — Fastify + Prisma + SQLite backend
- `shared/constants/` — JS + .d.ts shared with client (order statuses, delivery carriers, payment methods, upload limits)
- `client/` — frontend (React + Vite + TypeScript + MUI), **FSD architecture**: `app/pages/widgets/features/entities/shared`
- `server/` — backend (Fastify + Prisma + SQLite)
- `shared/constants/` — JS + `.d.ts` files shared between client and server (order statuses, delivery carriers, payment methods, upload limits)
## Developer commands
### Client (`cd client`)
| Command | What it does |
|---|---|
| `npm run dev` | node --env-file=.env --watch src/index.js (requires Node 20.6+) |
| `npm run dev:classic` | node --watch src/index.js (loads .env via dotenv) |
| `npm run dev` | Vite dev server on `:5173`, proxies `/api` and `/uploads` to `http://127.0.0.1:3333` |
| `npm run build` | Runs `tsc -b` first, then `vite build` |
| `npm run lint` | ESLint (flat config) |
| `npm run lint:fix` | ESLint with --fix |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm run format` | Prettier write all |
| `npm run format:check` | Prettier check only |
| `npm test` | vitest run |
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses .env) |
| `npm run test:watch` | vitest watch mode |
### Server (`cd server`)
| Command | What it does |
|---|---|
| `npm run dev` | `node --env-file=.env --watch src/index.js` (requires Node 20.6+) |
| `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) |
| `npm run lint` | ESLint (flat config) |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm run format` | Prettier write all |
| `npm run format:check` | Prettier check only |
| `npm test` | vitest run |
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.env`) |
### Build order (when changing both packages)
```bash
cd server && npm run db:migrate # if schema changed
cd server && npm test # server tests first
cd client && npm run lint && npm run format:check && npm test # then client
cd client && npm run build # full typecheck + build
```
## Conventions
- **Language**: Отвечай пользователю на русском.
- **Language**: Отвечай пользователю **на русском**.
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
- **Alias**: @shared → shared/ (configured in vitest.config.js for tests).
- **Admin access**: Only users with email matching ADMIN_EMAIL env var can access admin routes. Server auto-creates the admin user on startup.
- **Server helpers**: slugify, parseMaterialsInput, mapProductForApi are decorated on fastify instance, accessed via request.server.*.
- **FSD import boundaries** enforced by `eslint-plugin-boundaries`. Lower layers cannot import upper layers. If ESLint complains about an import, the architecture is wrong.
- **Aliases**: `@/``client/src/`, `@shared/``shared/` (configured in both vite.config.ts and tsconfig).
- **API requests**: Use `apiClient` (axios wrapper from `shared/api/`) with `@tanstack/react-query`. Invalidate queries after mutations.
- **UI**: Prefer MUI components over custom HTML/CSS.
- **`no-console`**: ESLint error; use `console.warn/error/info` only.
- **Admin access**: Only users with email matching `ADMIN_EMAIL` env var can access admin routes. Server auto-creates the admin user on startup.
- **Server helpers**: `slugify`, `parseMaterialsInput`, `mapProductForApi` are decorated on fastify instance, accessed via `request.server.*`.
## Testing
- Vitest with globals enabled.
- Test files live in __tests__/ directories next to the code they test.
- **Client**: vitest + jsdom + @testing-library/react. Setup file: `client/src/testing/setup.ts`.
- **Server**: vitest with globals enabled.
- Test files live in `__tests__/` directories next to the code they test.
## OAuth
- VK callback: {SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback
- Yandex callback: {SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback
- VK callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
- Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
- Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL`
## Deployment
## Infrastructure (deployment)
- Gitea CI/CD deploys to the server machine on push to main
- Traffic flow: Browser → Domain → Nginx (server machine) → Fastify (3333)
- trustProxy: true on Fastify
- **VPS** runs Nginx Proxy Manager (NPM), connected via Netbird peer-to-peer VPN to the dev machine
- **Local dev machine** runs the project (server + client), also a Netbird peer
- **Traffic flow**: Browser → Domain (A record → VPS IP) → NPM → Netbird tunnel → Local dev machine (`server:3333`)
- NPM manages SSL, domains, and proxy hosts
- `trustProxy: true` on Fastify — `request.ip` works correctly through NPM/Netbird chain
## Notable quirks
- .env is gitignored. Copy .env.example to .env for local dev.
- db:reset:test runs prisma migrate reset --force, which destroys all data.
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
- Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first.
- Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`.
- `db:reset:test` runs `prisma migrate reset --force`, which destroys all data.
+145 -1
View File
@@ -1 +1,145 @@
# Shop Server\n\nCraftshop API server\n\n## Deploy\nAuto-deploy via Gitea Actions on push to main
# Магазин изделий ручной работы https://любимыйкреатив.рф
Цель проекта — витрина и админка для магазина изделий ручного труда (игрушки, сувениры и т.п.) с простой загрузкой/редактированием данных через фронтенд‑админку.
Проект сделан как **монорепозиторий**:
- `client/` — фронтенд (витрина + админка)
- `server/` — бэкенд API + БД
## Стек
### Фронтенд
- **React** + **Vite**
- **axios**
- **@tanstack/react-query**
- **MUI (@mui/material)** + emotion
- **React Router**
- **Архитектура**: **FSD (Feature-Sliced Design)** — слои `app/pages/widgets/features/entities/shared`
- **Качество**: ESLint (flat config) + Prettier, границы FSD (`eslint-plugin-boundaries`)
### Бэкенд
- **Node.js**
- **Fastify** (+ CORS)
- **Prisma** (миграции)
- **SQLite** (локальная БД; легко сменить на Postgres через `DATABASE_URL`)
## Основные подходы и договорённости
### FSD на фронте
- Импорты между слоями ограничены правилами `boundaries` (например `features` может импортировать `entities/shared`, но не наоборот).
- Alias `@` указывает на `client/src` (см. `client/vite.config.ts` и `client/tsconfig.app.json`).
### Данные и админка
- Данные загружаются/редактируются через **админку на фронте**.
- Админ‑роуты бэкенда доступны только авторизованному пользователю с email из `ADMIN_EMAIL` в `server/.env`.
### Форматирование и линтинг (client)
- Prettier конфиг: `client/.prettierrc.json`
- Ignore: `client/.prettierignore`
- EditorConfig: `client/.editorconfig`
- Команды:
- `npm run lint` / `npm run lint:fix`
- `npm run format` / `npm run format:check`
## Запуск
### Бэкенд
**Вариант A — типовой `.env`**
```bash
cd server
cp .env.example .env # укажите ADMIN_EMAIL
npm install
npx prisma migrate dev # если база ещё не создана
npx prisma db seed # опционально: тестовые категории и товары
npm run dev:classic # загрузка из `.env`
```
**Вариант B — `.env` файл** (нужен **Node.js 20.6+** из‑за `node --env-file`):
```bash
cd server
cp .env.example .env # укажите ADMIN_EMAIL и другие настройки
npm install
npm run dev # переменные из `.env`
```
Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`.
Сервер: `http://127.0.0.1:3333`. Проверка: `GET /health`.
### Фронтенд
В другом терминале:
```bash
cd client
npm install
npm run dev
```
Откройте `http://localhost:5173`. Запросы к `/api` проксируются на бэкенд (см. `client/vite.config.ts`).
## Админка
Раздел админки доступен только по прямой ссылке `/admin` и только для пользователя с email из `ADMIN_EMAIL`. Если такого пользователя нет в БД, сервер создаёт его автоматически при старте.
Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
### OAuth VK и Яндекс
В `server/.env` задайте `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, а также **точные** публичные адреса:
- `SERVER_PUBLIC_URL` — базовый URL API (без завершающего `/`), например `https://api.example.com` или `http://127.0.0.1:3333`.
- `CLIENT_PUBLIC_URL` — базовый URL витрины, куда бэкенд редиректит после входа с JWT в query: `/auth/callback?token=...`, например `http://127.0.0.1:5173`.
**Redirect URI в кабинетах провайдеров** (должны совпадать с тем, что шлёт сервер при авторизации):
- VK: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
- Яндекс: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
Старт входа с витрины: кнопки на странице `/auth` ведут на `GET /api/auth/oauth/vk` и `GET /api/auth/oauth/yandex` (полный URL — тот же origin, что и API: при прокси Vite это `/api/...` относительно фронта; при отдельном домене API — из `VITE_API_URL`).
### Футер витрины (опционально)
В `client/.env` можно задать `VITE_STORE_EMAIL`, `VITE_STORE_PHONE`, `VITE_STORE_SOCIAL_NOTE` для блока контактов в подвале. Для страницы «Политика конфиденциальности» задайте **`VITE_PUBLIC_SITE_URL`** (например `https://example.com`, без завершающего `/`) — иначе в dev подставится текущий origin; на проде лучше указать явно перед `npm run build`.
## API (кратко)
Публичные:
- `GET /api/categories`
- `GET /api/products?categorySlug=...`
Админ:
- `GET /api/admin/products`
- `POST /api/admin/products`
- `PATCH /api/admin/products/:id`
- `DELETE /api/admin/products/:id`
- `POST /api/admin/categories`
## Деплой
```bash
# Заполнить scripts/deploy.env (DEPLOY_HOST, DEPLOY_PATH и т.д.)
# Первичная настройка LXC: см. scripts/SERVER_SETUP.md
# Деплой только изменившихся компонентов:
./scripts/deploy-auto.sh
# Полный деплой (игнорировать diff):
./scripts/deploy-auto.sh --force
# Только фронт или только бэкенд:
./scripts/deploy-auto.sh --frontend-only
./scripts/deploy-auto.sh --backend-only
```
+162
View File
@@ -0,0 +1,162 @@
# План рефакторинга shop
> Составлен на основе анализа кода и правил `.cursor/rules`.
> Дата: 2026-05-13
---
## Статус выполнения
- ✅ 1.1 Сервер: разбить `routes/auth.js` → 6 модулей
- ✅ 1.2 Клиент: разбить `AdminPage.tsx``AdminProductsPage` + `AdminCategoriesPage`
- ✅ 1.3 Клиент: разбить `OrderDetailPage.tsx` (чаты, оплата, отзывы → features)
- ✅ 2.2 FSD: роутинг из `App.tsx``app/routes/index.tsx`
- ✅ 3.1 Клиент: разбить `AppHeader.tsx` (UserMenu, CartBadge, NavigationDrawer)
- ✅ 4.1 Effector: рефакторинг `auth.ts` (persist, sample, createErrorStore)
- ✅ 2.1 Недостающие сегменты FSD (catalog-slider, gallery, info, address-map-picker)
- ✅ 2.3 Дублирование констант клиент/сервер
- ✅ 3.2 HomePage (вынесены фильтры в хук и компонент)
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
- ✅ 5.1 fastify.decorate вместо параметров
- ✅ 5.2 Валидация через Fastify Schema
- ✅ 6.1 Error Boundary
- ✅ 6.2 Тесты
---
## 1. Критические точки (высокий приоритет) — ✅ Выполнено
### 1.1 Сервер: разбить `server/src/routes/auth.js` (892 → ~200 строк)
| Файл | Роуты |
|---|---|
| `routes/auth.js` | `/api/auth/request-code`, `/api/auth/verify-code`, `/api/me`, `/api/me/change-email/*`, `/api/me/profile` |
| `routes/user-addresses.js` | `/api/me/addresses` (6 роутов CRUD + default) |
| `routes/user-cart.js` | `/api/me/cart` (4 роута CRUD) |
| `routes/user-orders.js` | `/api/me/orders` (создание, список, деталь, подтверждение, review-eligibility) |
| `routes/user-payments.js` | `/api/me/orders/:id/pay` |
| `routes/user-messages.js` | `/api/me/orders/:id/messages`, unread-count, conversations, mark-read |
### 1.2 Клиент: разбить `AdminPage.tsx` (891 → 604 + 295 строк)
- `pages/admin-products/` + `pages/admin-categories/`
- `AdminLayoutPage` — новый нав-айтем «Категории», роут `/admin/categories`
### 1.3 Клиент: разбить `OrderDetailPage.tsx` (609 → 258 строк)
- `features/order-chat/` — чат по заказу
- `features/order-payment/` — секция оплаты + модалка (`OrderPaymentSection`, `PaymentDialog`)
- `features/product-review/` — секция отзывов + модалка (`ReviewSection`, `ReviewDialog`)
---
## 2. FSD-архитектура
### 2.1 Создать недостающие сегменты ✅
| Слайс | Что сделано |
|---|---|
| `entities/catalog-slider` | `model/types.ts`, `index.ts` (barrel), импорты обновлены |
| `entities/gallery` | `ui/GalleryGrid.tsx`, `index.ts`, импорты обновлены |
| `entities/info` | `model/types.ts`, `index.ts`, импорты обновлены |
| `features/address-map-picker` | `api/map-geocoding.ts`, `model/types.ts`, `index.ts`, импорты обновлены |
### 2.2 Вынести роутинг из `App.tsx` → `app/routes/` ✅
`AppRoutes` в `app/routes/index.tsx`. `App.tsx` — чистая точка входа.
### 2.3 Устранить дублирование констант клиент/сервер ✅
Создан `shared/constants/` с каноничными значениями (`order-status.js`, `delivery-carrier.js`, `upload-limits.js`, `payment-method.js`). Все клиентские и серверные константы импортируются оттуда. Vite + tsconfig настроены на `@shared/*` alias.
---
## 3. Клиентские компоненты
### 3.1 `AppHeader.tsx` (406 → 293 строк) ✅
- `UserMenu``features/user/user-menu/`
- `CartBadge``features/cart/cart-badge/`
- `NavigationDrawer``widgets/navigation-drawer/`
### 3.2 `HomePage.tsx` (414 → 157 строк) ✅
- `useProductFilters` хук в `pages/home/lib/`
- `ProductFilters` компонент в `pages/home/ui/`
- Фильтры, сортировка, масштаб карточек вынесены из страницы
### 3.3 `AdminOrdersPage.tsx`, `AdminUsersPage.tsx` ✅
- `shared/ui/AdminTable/` — компонент таблицы с loading/error/skeleton
- `shared/ui/AdminDialog/` — компонент диалога с loading/error/title/actions
- `AdminUsersPage`: таблица и диалог заменены на общие компоненты
- `AdminOrdersPage`: диалог заменён на `AdminDialog`
---
## 4. Effector + состояние — ✅ Выполнено
### 4.1 `shared/model/auth.ts` (96 → 83 строк)
- `.watch()``sample` + `persistTokenFx`
- Убран `tokenPersistInitialized` флаг
- `createErrorStore(effect)` — общий шаблон сторов ошибок
- `readStoredToken``shared/lib/persist-token.ts` (re-export из auth.ts)
- Создан `shared/lib/create-error-store.ts`
---
## 5. Сервер (низкий приоритет)
### 5.1 `fastify.decorate` вместо передачи зависимостей параметрами ✅
`slugify`, `parseMaterialsInput`, `mapProductForApi` декорированы на fastify в `api.js`. Роуты используют `request.server.*` вместо получения через параметры.
### 5.2 Валидация через Fastify Schema ✅
Добавлены JSON Schema для:
- `POST /api/admin/products` — body
- `PATCH /api/admin/products/:id` — body
- `GET /api/products` — querystring (фильтры, пагинация)
---
## 6. Инфраструктура (низкий приоритет)
### 6.1 Error Boundary ✅
Создан `shared/ui/ErrorBoundary/ErrorBoundary.tsx` — class-компонент с `getDerivedStateFromError` / `componentDidCatch`.
- Отображает MUI `Alert` с заголовком «Что-то пошло не так» и кнопкой «Попробовать снова».
- Поддерживает кастомный `fallback` и колбэк `onError`.
- Интегрирован в `App.tsx`: `<ErrorBoundary><AppRoutes /></ErrorBoundary>`.
### 6.2 Тесты ✅
**Клиент (vitest + jsdom + @testing-library/react):**
- `shared/lib/__tests__/get-error-message.test.ts` — 4 теста
- `shared/lib/__tests__/format-price.test.ts` — 3 теста
- `shared/lib/__tests__/group-orders-by-status.test.ts` — 3 теста
- `shared/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx` — 4 теста (рендер, падение, кастомный fallback, сброс)
**Сервер (vitest):**
- `src/lib/__tests__/escape-html.test.js` — 4 теста
- `src/lib/__tests__/order-status.test.js` — 9 тестов (`canTransitionAdminOrderStatus`)
**Команды:** `npm test` (vitest run), `npm run test:watch` (vitest).
---
## Сводка изменений
| Область | Файлов создано | Файлов изменено |
|---|---|---|---|
| Server routes | 0 | 4 (декораты + схемы) |
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
| Client shared/ui | 3 | 0 (AdminDialog, AdminTable, ErrorBoundary) |
| Client app config | 0 | 2 (vite.config, tsconfig) |
| Client tests | 4 | 0 (vitest config, setup, 3 test files) |
| Server tests | 2 | 0 (vitest config, 2 test files) |
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
| **Итого** | **29** | **15** |
-1
View File
@@ -1 +0,0 @@
test
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[{*.md,*.mdx}]
trim_trailing_whitespace = false
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.vite
coverage
*.min.*
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 120,
"trailingComma": "all",
"endOfLine": "lf",
"jsxSingleQuote": false,
"arrowParens": "always"
}
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+204
View File
@@ -0,0 +1,204 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import importX from 'eslint-plugin-import-x'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettier from 'eslint-plugin-prettier'
import globals from 'globals'
import boundaries from 'eslint-plugin-boundaries'
import reactRefresh from 'eslint-plugin-react-refresh'
const fsdPathGroups = [
{ pattern: 'app/**', group: 'internal', position: 'before' },
{ pattern: 'pages/**', group: 'internal', position: 'before' },
{ pattern: 'widgets/**', group: 'internal', position: 'before' },
{ pattern: 'features/**', group: 'internal', position: 'before' },
{ pattern: 'entities/**', group: 'internal', position: 'before' },
{ pattern: 'shared/**', group: 'internal', position: 'before' },
// alias вида "@/shared/..."
{ pattern: '@/**', group: 'internal', position: 'before' },
]
/** Правила + FSD-границы. */
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**'],
},
{
name: 'react-plugin-settings',
settings: { react: { version: '19' } },
},
eslint.configs.recommended,
...tseslint.configs.recommended,
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
react.configs.flat.recommended,
react.configs.flat['jsx-runtime'],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
jsxA11y.flatConfigs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.es2021 },
},
settings: {
'import/internal-regex': '^(@/)?(app|pages|widgets|features|entities|shared)(/|$)',
'import/resolver': {
typescript: { project: './tsconfig.json' },
node: true,
},
},
rules: {
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'max-len': [
'warn',
{
code: 120,
ignoreStrings: true,
ignoreTrailingComments: true,
ignoreTemplateLiterals: true,
ignoreComments: true,
},
],
'import-x/extensions': [
'warn',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
json: 'always',
svg: 'always',
},
],
'import-x/prefer-default-export': 'off',
'import-x/no-extraneous-dependencies': 'off',
'import-x/no-cycle': 'warn',
'import-x/order': [
'warn',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
pathGroups: [
{ pattern: 'react', group: 'external', position: 'before' },
{ pattern: 'react-dom', group: 'external', position: 'before' },
{ pattern: '@mui/**', group: 'external', position: 'before' },
...fsdPathGroups,
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'never',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'react/prop-types': 'off',
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'ignore' }],
'react/display-name': 'off',
'react/no-unescaped-entities': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
'no-unused-vars': 'off',
'@typescript-eslint/no-shadow': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-use-before-define': 'error',
'no-use-before-define': 'off',
'consistent-return': 'off',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unnecessary-type-constraint': 'warn',
'class-methods-use-this': 'warn',
},
},
{
files: ['**/*.{ts,tsx}'],
plugins: { prettier: eslintPluginPrettier },
rules: { 'prettier/prettier': ['warn', { endOfLine: 'lf' }] },
},
eslintConfigPrettier,
{
files: ['**/*.{ts,tsx}'],
plugins: { boundaries },
languageOptions: {
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
settings: {
'import/resolver': {
typescript: { project: './tsconfig.json' },
},
'boundaries/include': ['src/**/*'],
'boundaries/elements': [
{ type: 'app', pattern: 'src/app/**' },
{ type: 'pages', pattern: 'src/pages/**' },
{ type: 'widgets', pattern: 'src/widgets/**' },
{ type: 'features', pattern: 'src/features/**' },
{ type: 'entities', pattern: 'src/entities/**' },
{ type: 'shared', pattern: 'src/shared/**' },
],
},
rules: {
'boundaries/no-unknown': 'off',
'boundaries/no-unknown-files': 'off',
'boundaries/dependencies': [
'error',
{
default: 'disallow',
checkUnknownLocals: true,
rules: [
{ from: { type: 'shared' }, allow: { to: { type: 'shared' } } },
{
from: { type: 'entities' },
allow: { to: { type: ['entities', 'shared'] } },
},
{
from: { type: 'features' },
allow: { to: { type: ['features', 'entities', 'shared'] } },
},
{
from: { type: 'widgets' },
allow: {
to: { type: ['widgets', 'features', 'entities', 'shared'] },
},
},
{
from: { type: 'pages' },
allow: {
to: {
type: ['pages', 'widgets', 'features', 'entities', 'shared'],
},
},
},
{
from: { type: 'app' },
allow: {
to: {
type: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
},
},
},
],
},
],
},
},
{
files: ['src/app/providers/theme-controller.tsx'],
rules: { 'react-refresh/only-export-components': 'off' },
},
{
files: ['src/pages/**/ui/**/*.tsx'],
rules: { 'react-hooks/incompatible-library': 'off' },
},
{
files: ['eslint.config.js'],
rules: {
'import-x/no-unresolved': 'off',
'import-x/no-named-as-default': 'off',
'import-x/no-named-as-default-member': 'off',
},
},
)
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://xn--80abekoceifm0c0a5irb.xn--p1ai" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Любимый Креатив — изделия ручной работы: игрушки, сувениры и другие уникальные товары с душой и вниманием к деталям."
/>
<meta name="theme-color" content="#1976d2" />
<title>Любимый Креатив — Изделия ручной работы</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
<meta property="og:image" content="/favicon-128.png" />
<meta property="og:locale" content="ru_RU" />
<link rel="preload" href="/fonts/Outfit-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/fonts/Outfit-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="canonical" href="https://любимыйкреатив.рф/" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+10680
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"lint:fix": "eslint . --fix",
"format": "prettier . --write --ignore-unknown",
"format:check": "prettier . --check --ignore-unknown",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dicebear/adventurer": "^9.4.2",
"@dicebear/avataaars": "^9.4.2",
"@dicebear/big-ears": "^9.4.2",
"@dicebear/big-smile": "^9.4.2",
"@dicebear/bottts": "^9.4.2",
"@dicebear/core": "^9.4.2",
"@dicebear/croodles": "^9.4.2",
"@dicebear/fun-emoji": "^9.4.2",
"@dicebear/identicon": "^9.4.2",
"@dicebear/initials": "^9.4.2",
"@dicebear/lorelei": "^9.4.2",
"@dicebear/micah": "^9.4.2",
"@dicebear/notionists": "^9.4.2",
"@dicebear/pixel-art": "^9.4.2",
"@dicebear/rings": "^9.4.2",
"@dicebear/shapes": "^9.4.2",
"@dicebear/thumbs": "^9.4.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"axios": "^1.15.2",
"effector": "^23.4.4",
"effector-react": "^23.3.0",
"lucide-react": "^1.14.0",
"maplibre-gl": "^5.24.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"react-map-gl": "^8.1.1",
"react-router-dom": "^7.14.2",
"swiper": "^12.1.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^26.1.0",
"prettier": "^3.8.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10",
"vitest": "^3.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#546E7A"/><text x="16" y="23" text-anchor="middle" font-size="22" fill="white" font-family="sans-serif" font-weight="bold">К</text></svg>

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+106
View File
@@ -0,0 +1,106 @@
Политика в отношении обработки персональных данных
1. Общие положения
Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. №152-ФЗ «О персональных данных» и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые индивидуальным предпринимателем Новоселовой Наталией Владимировной (далее – Оператор).
1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее – Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта www.craftedtoys.ru. Оператор не контролирует и не несет ответственности за сайты третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на www.craftedroys.ru.
2. Основные понятия, используемые в Политике
2.1. Автоматизированная обработка персональных данных – обработка персональных данных с помощью средств вычислительной техники;
2.2. Блокирование персональных данных – временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных);
2.3. Веб-сайт – совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу www.craftedtoys.ru;
2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных, и обеспечивающих их обработку информационных технологий и технических средств;
2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных;
2.6. Обработка персональных данных – любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных; Оператор осуществляет обработку данных пользователя до момента подачи им заявления на отзыв согласия на обработку персональных данных
2.7. Оператор – Администрация сайта, индивидуальный предприниматель Индивидуальный предприниматель Новоселова Наталия Владимировна
ИНН 402900832341
ОГРНИП 305402922700051
Адрес: 248000, Россия, г. Калуга, ул. Никитина, д. 12А
2.8. Персональные данные – любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта www.craftedtoys.ru;
2.9. Пользователь – любой посетитель веб-сайта www.craftedtoys.ru;
2.10. Предоставление персональных данных – действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц;
2.11. Распространение персональных данных – любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц передача персональных данных или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом;
2.12. Уничтожение персональных данных – любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и (или) уничтожаются материальные носители персональных данных.
3. Оператор может обрабатывать следующие персональные данные Пользователя
3.1. Персональная информация, которую Пользователь предоставляет о себе самостоятельно при регистрации (создании учетной записи) или в процессе использования Сайта и его сервисов, включая персональные данные Пользователя. Обязательная для предоставления Сервисов информация помечена специальным образом. Иная информация предоставляется Пользователем на его усмотрение.
3.2. Данные, которые автоматически передаются сервисам Сайта в процессе их использования с помощью установленного на устройстве Пользователя программного обеспечения (а именно программ Yandex.Metrika (предоставляется ООО “Яндекс”), в том числе IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы) и иная подобная информация.
4. Категории собираемых персональных данных и цели их обработки
4.1. Сайт собирает и хранит только ту персональную информацию, которая необходима для предоставления информации об услугах или исполнения соглашений и договоров с Пользователем, за исключением случаев, когда законодательством предусмотрено обязательное хранение персональной информации в течение определенного законом срока.
4.2. Персональную информацию Пользователя Сайт обрабатывает в следующих целях:
4.2.1. Установления с Пользователем обратной связи, включая направление уведомлений, запросов, касающихся использования Сайта, оказания услуг, обработку запросов и заявок от Пользователя.
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
4.2.2. Идентификации Пользователя, зарегистрированного на Сайте, для формирования и исполнения персонализированных предложений и соглашений, а также предоставление Пользователю доступа к персонализированным ресурсам Сайта.
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
4.2.3. Предоставления Пользователю эффективной клиентской и технической поддержки.
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
4.3. Обезличенные данные Пользователей, собираемые с помощью сервисов интернет-статистики (а именно с помощью программ Yandex.Metrika (предоставляется ООО “Яндекс”), служат для сбора информации о действиях Пользователей на сайте, улучшения качества сайта и его содержания.
Для достижения данной цели Оператор собирает и обрабатывает следующие категории обезличенных данных: IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы). Указанные данные обрабатываются машинным способом. Срок обработки и хранения обезличенных данных, собираемых в соответствии с настоящим пунктом, составляет не более 3 лет с момента последнего посещения Пользователем Сайта.
5. Правовые основания обработки персональных данных
5.1. Оператор обрабатывает персональные данные Пользователя только в случае их заполнения и/или отправки Пользователем самостоятельно через специальные формы, расположенные на сайте www.craftedtoys.ru. Заполняя соответствующие формы и/или отправляя свои персональные данные Оператору, Пользователь выражает свое согласие с данной Политикой.
5.2. Оператор обрабатывает обезличенные данные о Пользователе в случае, если это разрешено в настройках браузера Пользователя (включено сохранение файлов «cookie» и использование технологии JavaScript).
5.3. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных;
5.4 Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем.
6. Порядок сбора, хранения, передачи и других видов обработки персональных данных
6.1. Персональная информация Пользователей хранится на территории Российской Федерации с соблюдением всех требований, установленных действующим российским законодательством.
6.2. В отношении персональной информации Пользователя сохраняется ее конфиденциальность, кроме случаев добровольного предоставления Пользователем информации о себе для общего доступа неограниченному кругу лиц (например, публикация отзывов). В таких случаях Пользователь соглашается с тем, что определенная часть его персональной информации становится общедоступной.
6.3. Сайт вправе передать персональную информацию Пользователя третьим лицам в следующих случаях:
6.3.1. Пользователь выразил согласие на такие действия и был проинформирован, какому конкретному третьему лицу и какой объем персональных данных будет передан.
6.3.2. Передача необходима для использования Пользователем определенного сервиса либо для исполнения определенного соглашения или договора с Пользователем.
6.3.3. Передача предусмотрена российским или иным применимым законодательством в рамках установленной законодательством процедуры.
6.4. Обработка персональных данных Пользователя осуществляется любым законным способом, в том числе в информационных системах персональных данных с использованием средств автоматизации или без использования таких средств. Обработка персональных данных Пользователей осуществляется в соответствии с Федеральным законом от 27.07.2006 N 152-ФЗ "О персональных данных". Срок обработки и хранения персональных данных, собираемых Оператором на сайте составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования, за исключением случаев, предусмотренных пунктами 2 - 11 части 1 статьи 6 Федерального закона “О персональных данных”. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
6.5. При утрате или разглашении персональных данных Администрация Сайта информирует Пользователя об утрате или разглашении персональных данных.
6.6. Администрация Сайта принимает необходимые организационные и технические меры для защиты персональной информации Пользователя от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий третьих лиц.
6.7. Администрация Сайта совместно с Пользователем принимает все необходимые меры по предотвращению убытков или иных отрицательных последствий, вызванных утратой или разглашением персональных данных Пользователя.
7. Ответственность
7.1. Администрация Сайта, не исполнившая свои обязательства, несет ответственность за убытки, понесенные Пользователем в связи с неправомерным использованием персональных данных, в соответствии с законодательством Российской Федерации.
7.2. В случае утраты или разглашения конфиденциальной информации Администрация Сайта не несет ответственности, если данная конфиденциальная информация:
7.2.1. Стала публичным достоянием до ее утраты или разглашения.
7.2.2. Была получена от третьей стороны до момента ее получения Администрацией Сайта.
7.2.3. Была разглашена с согласия Пользователя.
8. Заключительные положения:
8.1. Администрация Сайта вправе вносить изменения в настоящую Политику конфиденциальности без согласия Пользователя.
8.2. Новая Политика конфиденциальности вступает в силу с момента ее размещения на Сайте, если иное не предусмотрено новой редакцией Политики конфиденциальности.
8.3. Все предложения или вопросы по настоящей Политике конфиденциальности следует сообщать на электронный адрес toy75@mail.ru
8.4. Действующая Политика конфиденциальности размещена на странице по адресу: https://craftedtoys.ru/rules/
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://любимыйкреатив.рф/sitemap.xml
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://любимыйкреатив.рф/</loc>
<priority>1.0</priority>
<changefreq>daily</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/info</loc>
<priority>0.8</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/about</loc>
<priority>0.7</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/privacy</loc>
<priority>0.5</priority>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://любимыйкреатив.рф/terms</loc>
<priority>0.5</priority>
<changefreq>yearly</changefreq>
</url>
</urlset>
+22
View File
@@ -0,0 +1,22 @@
import { BrowserRouter } from 'react-router-dom'
import { AppProviders } from '@/app/providers/AppProviders'
import { AppRoutes } from '@/app/routes'
import { NotificationStack } from '@/shared/ui/NotificationStack'
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
import { DemoOverlay } from '@/shared/ui/DemoOverlay'
export function App() {
return (
<AppProviders>
<BrowserRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<NotificationStack />
<NoiseOverlay />
<DemoOverlay />
</BrowserRouter>
</AppProviders>
)
}
+202
View File
@@ -0,0 +1,202 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import { alpha, useTheme } from '@mui/material/styles'
import Toolbar from '@mui/material/Toolbar'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Menu, Package } from 'lucide-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useThemeController } from '@/app/providers/theme-controller'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { CartBadge } from '@/features/cart/cart-badge'
import { UserMenu } from '@/features/user/user-menu'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
import type { ColorScheme } from '@/shared/model/theme'
import { BearLogo } from '@/shared/ui/BearLogo'
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
import { NavigationDrawer } from '@/widgets/navigation-drawer'
type NavItem = { label: string; to: string }
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
export const AppHeader = React.memo(function AppHeader() {
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const isAdmin = Boolean(user?.isAdmin)
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user) && !isAdmin,
})
const cartCount = cartQuery.data?.items?.length ?? 0
const ordersQuery = useQuery({
queryKey: ['me', 'orders'],
queryFn: fetchMyOrders,
enabled: Boolean(user) && !isAdmin,
})
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
).length
const [mobileOpen, setMobileOpen] = useState(false)
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const handler = () => setScrolled(window.scrollY > 0)
handler()
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
const go = (to: string) => {
setMobileOpen(false)
navigate(to)
}
const onLogout = () => {
tokenSet(null)
logout()
setMobileOpen(false)
navigate('/')
}
return (
<>
<AppBar
position="sticky"
color="primary"
elevation={scrolled ? 2 : 0}
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: alpha(theme.palette.primary.main, 0.95),
backdropFilter: 'blur(8px)',
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
}}
>
<Toolbar
sx={{
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
}}
>
{isMobile && (
<IconButton
color="inherit"
onClick={() => setMobileOpen(true)}
aria-label="Открыть меню"
edge="start"
sx={{ mr: 1 }}
>
<Menu />
</IconButton>
)}
<Box
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
textDecoration: 'none',
color: 'inherit',
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{STORE_NAME}
</Typography>
</Box>
{!isMobile &&
headerNavItems.map((i) => (
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
{i.label}
</Button>
))}
{!isAdmin && (
<>
{user && (
<Tooltip title="Заказы">
<IconButton
color="inherit"
sx={{ ml: 1 }}
onClick={() => navigate('/me/orders')}
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
>
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
<Package />
</Badge>
</IconButton>
</Tooltip>
)}
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
</>
)}
{!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
{isAdmin && user && !isMobile && (
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
)}
{!isMobile && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: 'rgba(255, 255, 255, 0.25)',
borderRadius: 3,
px: 0.5,
py: 0.5,
}}
>
<SchemeSwitcher value={scheme} onChange={(s: ColorScheme) => setScheme(s)} />
</Box>
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={cycleMode} />
</Box>
)}
</Toolbar>
</AppBar>
<NavigationDrawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
user={user}
isAdmin={isAdmin}
navItems={headerNavItems}
scheme={scheme}
mode={mode}
resolvedMode={resolvedMode}
onSchemeChange={(s: ColorScheme) => setScheme(s)}
onCycleMode={cycleMode}
onNavigate={go}
onLogout={onLogout}
/>
</>
)
})
+144
View File
@@ -0,0 +1,144 @@
import { type PropsWithChildren } from 'react'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
import { DemoBanner } from '@/shared/ui/DemoBanner'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: 0, overflowX: 'hidden' }}>
<ScrollOnNavigate />
<ScrollToTop />
<AppHeader />
<DemoBanner />
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
{children}
</Container>
</Box>
<Box
component="footer"
sx={{
mt: 'auto',
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
py: { xs: 5, md: 7 },
}}
>
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
<Grid container spacing={5}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}
>
{STORE_NAME}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
Изделия ручной работы: вещи с характером и вниманием к деталям.
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Покупателям
</Typography>
<Stack spacing={1.5}>
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
Личный кабинет
</Link>
<Link component={RouterLink} to="/info" color="inherit" underline="hover" variant="body2">
О покупке
</Link>
<Link component={RouterLink} to="/about" color="inherit" underline="hover" variant="body2">
О нас
</Link>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Контакты
</Typography>
<Stack spacing={1}>
<Typography variant="body2">
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
{STORE_EMAIL}
</Link>
</Typography>
<Typography variant="body2">
Телефон:{' '}
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
{STORE_PHONE}
</Link>
</Typography>
<Link
href={VK_URL}
target="_blank"
rel="noopener noreferrer"
color="text.secondary"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
>
<Box component="img" src={vkLogoSrc} alt="" sx={{ width: 20, height: 20 }} />
VK
</Link>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Юридическая информация
</Typography>
<Stack spacing={1.5}>
<Link component={RouterLink} to="/privacy" color="inherit" underline="hover" variant="body2">
Политика конфиденциальности
</Link>
<Link component={RouterLink} to="/terms" color="inherit" underline="hover" variant="body2">
Пользовательское соглашение
</Link>
</Stack>
</Grid>
</Grid>
<Divider sx={{ my: 4 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
© {year} {STORE_NAME}
</Typography>
</Box>
</Container>
</Box>
<CookieConsentBanner />
</Box>
)
}
@@ -0,0 +1,36 @@
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { MainLayout } from '../MainLayout'
vi.mock('@/app/layout/AppHeader', () => ({
AppHeader: () => <header>Шапка</header>,
}))
vi.mock('@/shared/ui/CookieConsentBanner', () => ({
CookieConsentBanner: () => null,
}))
vi.mock('@/shared/ui/DemoBanner', () => ({
DemoBanner: () => null,
}))
vi.mock('@/shared/ui/ScrollOnNavigate', () => ({
ScrollOnNavigate: () => null,
}))
vi.mock('@/shared/ui/ScrollToTop', () => ({
ScrollToTop: () => null,
}))
describe('MainLayout', () => {
it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => {
const { container } = render(
<MemoryRouter>
<MainLayout>Контент</MainLayout>
</MemoryRouter>,
)
expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' })
})
})
+361
View File
@@ -0,0 +1,361 @@
import { type PropsWithChildren, useMemo } from 'react'
import CssBaseline from '@mui/material/CssBaseline'
import { alpha, ThemeProvider, createTheme } from '@mui/material/styles'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
import { SseProvider } from './SseProvider'
function AppThemeInner({ children }: PropsWithChildren) {
const controller = useThemeController()
const isDark = controller.resolvedMode === 'dark'
const theme = useMemo(
() =>
createTheme({
palette: (() => {
const common = { mode: controller.resolvedMode }
const text = isDark
? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' }
: { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' }
const chip = isDark ? { default: '#0E1510', paper: '#121B14' } : { default: '#F6FAF6', paper: '#FFFFFF' }
switch (controller.scheme) {
case 'forest':
return {
...common,
primary: { main: isDark ? '#8FBC8F' : '#2E8B57' },
secondary: { main: isDark ? '#CD853F' : '#8B4513' },
info: { main: isDark ? '#4682B4' : '#1E90FF' },
success: { main: isDark ? '#90EE90' : '#32CD32' },
warning: { main: isDark ? '#FFD700' : '#FFA500' },
error: { main: isDark ? '#F08080' : '#CD5C5C' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#0F1720', paper: '#1A242E' }
: { default: '#F8F6F3', paper: '#FFFFFF' },
}
case 'ocean':
return {
...common,
primary: { main: isDark ? '#5F9EA0' : '#20B2AA' },
secondary: { main: isDark ? '#7B68EE' : '#6A5ACD' },
info: { main: isDark ? '#87CEEB' : '#00BFFF' },
success: { main: isDark ? '#98FB98' : '#00FA9A' },
warning: { main: isDark ? '#FFE4B5' : '#FFDAB9' },
error: { main: isDark ? '#FF6347' : '#FF4500' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#0A1A2A', paper: '#0F1D35' }
: { default: '#F0F8FF', paper: '#FFFFFF' },
}
case 'berry':
return {
...common,
primary: { main: isDark ? '#9370DB' : '#8A2BE2' },
secondary: { main: isDark ? '#FF69B4' : '#FF1493' },
info: { main: isDark ? '#00CED1' : '#00BFFF' },
success: { main: isDark ? '#00FF7F' : '#7CFC00' },
warning: { main: isDark ? '#FFD700' : '#FFA500' },
error: { main: isDark ? '#FF4500' : '#FF6347' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#1A0A1A', paper: '#250E25' }
: { default: '#FFF0F5', paper: '#FFFFFF' },
}
case 'craft':
default:
return {
...common,
primary: { main: isDark ? '#90A4AE' : '#546E7A' },
secondary: { main: isDark ? '#78909C' : '#78909C' },
info: { main: isDark ? '#7986CB' : '#3F51B5' },
success: { main: isDark ? '#66BB6A' : '#43A047' },
warning: { main: isDark ? '#FFB74D' : '#F57C00' },
error: { main: isDark ? '#EF5350' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#121212', paper: '#1E1E1E' }
: { default: '#F5F5F5', paper: '#FFFFFF' },
}
}
})(),
shape: { borderRadius: 12 },
typography: {
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
h3: { fontWeight: 700, letterSpacing: '-0.5px', lineHeight: 1.2, textWrap: 'balance' },
h4: { fontWeight: 700, letterSpacing: '-0.5px', textWrap: 'balance' },
h5: { fontWeight: 600, letterSpacing: '-0.25px', textWrap: 'balance' },
h6: { fontWeight: 600, textWrap: 'balance' },
subtitle1: { fontWeight: 600 },
subtitle2: { fontWeight: 500 },
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
body2: { fontSize: '0.75rem', lineHeight: 1.5 },
button: { textTransform: 'none', fontWeight: 600 },
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 12,
fontWeight: 600,
transition: 'all 0.2s ease-in-out',
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
contained: {
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
'&:hover': {
boxShadow: '0 6px 20px 0 rgba(0,0,0,0.18)',
transform: 'translateY(-2px)',
},
'&:active': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
transform: 'translateY(0) scale(0.98)',
},
},
outlined: {
border: '1px solid',
'&:hover': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
},
'&:active': {
boxShadow: 'none',
transform: 'scale(0.98)',
},
},
text: {
'&:hover': {
backgroundColor: 'action.hover',
},
'&:active': {
backgroundColor: 'action.selected',
},
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
transition: 'all 0.2s ease-in-out',
'&:hover': {
backgroundColor: 'action.hover',
transform: 'scale(1.08)',
},
'&:active': {
backgroundColor: 'action.selected',
transform: 'scale(0.95)',
},
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiLink: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
borderRadius: 2,
},
},
},
},
MuiInputBase: {
styleOverrides: {
root: {
'&.Mui-focused': {
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: 2,
},
},
},
},
},
MuiAlert: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
boxShadow: 'none',
fontWeight: 500,
alignItems: 'center',
padding: '8px 12px',
'& .MuiAlert-icon': {
padding: 0,
marginRight: 12,
display: 'flex',
alignItems: 'center',
},
'& .MuiAlert-message': {
padding: 0,
},
'& .MuiAlert-action': {
padding: 0,
marginRight: 0,
marginLeft: 8,
},
},
colorSuccess: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.success
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorError: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.error
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorWarning: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.warning
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorInfo: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.info
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
},
},
MuiSnackbarContent: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
bgcolor: isDark ? '#1E1E1E' : '#FFFFFF',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
color: isDark ? '#F2F2F2' : '#1F1B16',
fontWeight: 500,
},
},
},
},
}),
[controller.resolvedMode, controller.scheme],
)
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}
export function AppProviders({ children }: PropsWithChildren) {
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
[],
)
return (
<QueryClientProvider client={queryClient}>
<SseProvider />
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { createEventStream } from '@/shared/lib/sse'
import { $token } from '@/shared/model/auth'
export function SseProvider() {
const token = useUnit($token)
const queryClient = useQueryClient()
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (!token) {
if (sourceRef.current) {
sourceRef.current.close()
sourceRef.current = null
}
return
}
const es = createEventStream(token)
sourceRef.current = es
function invalidateOrderQueries(orderId: unknown) {
if (!orderId) return
queryClient.invalidateQueries({ queryKey: ['me', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
}
function handleEvent(eventName: string) {
return function (event: MessageEvent) {
try {
const data = JSON.parse(event.data)
const orderId = data.orderId
switch (eventName) {
case 'message:new':
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
invalidateOrderQueries(orderId)
break
case 'order:statusChanged':
invalidateOrderQueries(orderId)
break
case 'order:updated':
invalidateOrderQueries(orderId)
break
case 'order:new':
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
break
}
} catch (err) {
console.warn('[sse] Failed to parse event data', err)
}
}
}
const messageNewHandler = handleEvent('message:new')
const orderStatusHandler = handleEvent('order:statusChanged')
const orderUpdatedHandler = handleEvent('order:updated')
const orderNewHandler = handleEvent('order:new')
es.addEventListener('message:new', messageNewHandler)
es.addEventListener('order:statusChanged', orderStatusHandler)
es.addEventListener('order:updated', orderUpdatedHandler)
es.addEventListener('order:new', orderNewHandler)
return () => {
es.removeEventListener('message:new', messageNewHandler)
es.removeEventListener('order:statusChanged', orderStatusHandler)
es.removeEventListener('order:updated', orderUpdatedHandler)
es.removeEventListener('order:new', orderNewHandler)
es.close()
sourceRef.current = null
}
}, [token, queryClient])
return null
}
@@ -0,0 +1,159 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SseProvider } from '../SseProvider'
const mockInvalidateQueries = vi.fn()
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
})
vi.mock('@/shared/model/auth', () => ({
$token: {
defaultState: null,
subscribe: () => () => {},
getState: () => null,
watch: () => () => {},
on: () => {},
reset: () => {},
},
}))
let mockToken: string | null = null
let mockEventHandlers: Record<string, (event: MessageEvent) => void> = {}
let mockCloseCalls = 0
class MockEventSource {
url: string
constructor(url: string) {
this.url = url
mockCloseCalls = 0
mockEventHandlers = {}
}
addEventListener(type: string, handler: (event: MessageEvent) => void) {
mockEventHandlers[type] = handler
}
removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
delete mockEventHandlers[type]
}
close() {
mockCloseCalls++
}
}
vi.mock('@/shared/lib/sse', () => ({
createEventStream: (token: string) => {
mockToken = token
return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
},
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => mockToken }
})
function renderSse() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<SseProvider />
</QueryClientProvider>,
)
}
describe('SseProvider', () => {
afterEach(() => {
mockToken = null
mockInvalidateQueries.mockReset()
mockCloseCalls = 0
mockEventHandlers = {}
})
it('renders nothing (returns null)', () => {
mockToken = null
const { container } = renderSse()
expect(container.innerHTML).toBe('')
})
it('does not create EventSource when token is null', () => {
mockToken = null
renderSse()
expect(mockToken).toBeNull()
})
it('creates EventSource when token is set', () => {
mockToken = 'test-jwt'
renderSse()
expect(mockToken).toBe('test-jwt')
})
it('closes EventSource on unmount', () => {
mockToken = 'test-jwt'
const { unmount } = renderSse()
expect(mockCloseCalls).toBe(0)
unmount()
expect(mockCloseCalls).toBe(1)
})
it('invalidates unread-count and conversations on message:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(handler).toBeDefined()
handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates order queries on order:statusChanged', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:statusChanged']
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates order queries on order:updated', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:updated']
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates admin queries on order:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:new']
handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
})
it('handles invalid JSON gracefully', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(() => {
handler(new MessageEvent('message:new', { data: ':heartbit' }))
}).not.toThrow()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,113 @@
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import type { PaletteMode } from '@mui/material'
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
export type ThemeSettings = {
mode: ThemeModePreference
scheme: ColorScheme
}
export type ThemeController = ThemeSettings & {
/** Итоговый режим, учитывая system. */
resolvedMode: PaletteMode
setMode: (mode: ThemeModePreference) => void
toggleMode: () => void
cycleMode: () => void
setScheme: (scheme: ColorScheme) => void
}
const THEME_STORAGE_KEY = 'craftshop_theme'
function readStoredTheme(): ThemeSettings | null {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
const mode: unknown = parsed?.mode
const scheme: unknown = parsed?.scheme
const modeOk = mode === 'light' || mode === 'dark' || mode === 'system'
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
if (!modeOk || !schemeOk) return null
return { mode, scheme }
} catch (err) {
console.warn('[theme] Failed to read stored theme', err)
return null
}
}
function getSystemMode(): PaletteMode {
if (typeof window === 'undefined') return 'light'
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function resolveMode(pref: ThemeModePreference): PaletteMode {
return pref === 'system' ? getSystemMode() : pref
}
const ThemeControllerContext = createContext<ThemeController | null>(null)
export function useThemeController(): ThemeController {
const ctx = useContext(ThemeControllerContext)
if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider')
return ctx
}
export function ThemeControllerProvider({ children }: PropsWithChildren) {
const [settings, setSettings] = useState<ThemeSettings>(
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
)
const [systemMode, setSystemMode] = useState<PaletteMode>(() => getSystemMode())
useEffect(() => {
const mql = window.matchMedia?.('(prefers-color-scheme: dark)')
if (!mql) return
const handler = () => setSystemMode(mql.matches ? 'dark' : 'light')
// начальное значение
handler()
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}
// Safari старых версий
mql.addListener(handler)
return () => mql.removeListener(handler)
}, [])
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
} catch (err) {
console.warn('[theme] Failed to persist theme setting', err)
}
}, [settings])
const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode
const controller = useMemo<ThemeController>(
() => ({
mode: settings.mode,
resolvedMode,
scheme: settings.scheme,
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
toggleMode: () =>
setSettings((s) => ({
...s,
mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light',
})),
cycleMode: () =>
setSettings((s) => ({
...s,
mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system',
})),
setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })),
}),
[resolvedMode, settings.mode, settings.scheme],
)
return <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
}
+135
View File
@@ -0,0 +1,135 @@
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { usePageTitleReset } from '@/shared/lib/use-page-title'
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage })))
const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage })))
const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage })))
const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage })))
const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage })))
const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage })))
const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage })))
const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage })))
const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage })))
const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage })))
export function AppRoutes() {
usePageTitleReset()
return (
<MainLayout>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<SkeletonPage />}>
<HomePage />
</Suspense>
}
/>
<Route
path="/admin/*"
element={
<Suspense fallback={<SkeletonPage />}>
<AdminLayoutPage />
</Suspense>
}
/>
<Route
path="/auth"
element={
<Suspense fallback={<SkeletonPage />}>
<AuthPage />
</Suspense>
}
/>
<Route
path="/auth/callback"
element={
<Suspense fallback={<SkeletonPage />}>
<AuthCallbackPage />
</Suspense>
}
/>
<Route
path="/cart"
element={
<Suspense fallback={<SkeletonPage />}>
<CartPage />
</Suspense>
}
/>
<Route
path="/checkout"
element={
<Suspense fallback={<SkeletonPage />}>
<CheckoutPage />
</Suspense>
}
/>
<Route
path="/about"
element={
<Suspense fallback={<SkeletonPage />}>
<AboutPage />
</Suspense>
}
/>
<Route
path="/info"
element={
<Suspense fallback={<SkeletonPage />}>
<InfoPage />
</Suspense>
}
/>
<Route
path="/privacy"
element={
<Suspense fallback={<SkeletonPage />}>
<PrivacyPolicyPage />
</Suspense>
}
/>
<Route
path="/terms"
element={
<Suspense fallback={<SkeletonPage />}>
<TermsPage />
</Suspense>
}
/>
<Route
path="/me/*"
element={
<Suspense fallback={<SkeletonPage />}>
<MeLayoutPage />
</Suspense>
}
/>
<Route
path="/products/:id"
element={
<Suspense fallback={<SkeletonPage />}>
<ProductPage />
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense fallback={<SkeletonPage />}>
<NotFoundPage />
</Suspense>
}
/>
</Routes>
</MainLayout>
)
}
+45
View File
@@ -0,0 +1,45 @@
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 500;
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 600;
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
font-display: swap;
}
:root {
color-scheme: light;
}
html {
scroll-behavior: smooth;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+21
View File
@@ -0,0 +1,21 @@
import type { CartItem } from '@/entities/cart/model/types'
import { apiClient } from '@/shared/api/client'
export type CartResponse = { items: CartItem[] }
export async function fetchMyCart(): Promise<CartResponse> {
const { data } = await apiClient.get<CartResponse>('me/cart')
return data
}
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
await apiClient.post('me/cart/items', body)
}
export async function setCartQty(id: string, qty: number): Promise<void> {
await apiClient.patch(`me/cart/items/${id}`, { qty })
}
export async function removeCartItem(id: string): Promise<void> {
await apiClient.delete(`me/cart/items/${id}`)
}
+3
View File
@@ -0,0 +1,3 @@
export type { CartItem } from './model/types'
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
export type { CartResponse } from './api/cart-api'
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { fetchMyCart } from '../api/cart-api'
import { $user } from '@/shared/model/auth'
export function useCartQuery() {
const user = useUnit($user)
return useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
}
+7
View File
@@ -0,0 +1,7 @@
import type { Product } from '@/entities/product/model/types'
export type CartItem = {
id: string
qty: number
product: Product
}
@@ -0,0 +1,19 @@
import { apiClient } from '@/shared/api/client'
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
return data
}
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
return data
}
export async function putAdminCatalogSlider(body: {
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
return data
}
@@ -0,0 +1,2 @@
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
@@ -0,0 +1,10 @@
export type CatalogSliderSlide = {
id: string
url: string
caption: string
textColor?: string
}
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
galleryImageId: string
}
@@ -0,0 +1,52 @@
import type { GalleryImageItem } from '@/entities/gallery/model/types'
import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
return data
}
export async function deleteGalleryImage(id: string): Promise<void> {
await apiClient.delete(`admin/gallery/${id}`)
}
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
for (const f of files) {
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
throw new Error(
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
)
}
}
const fd = new FormData()
for (const f of files) {
fd.append('files', f, f.name)
}
const token = localStorage.getItem('craftshop_auth_token')
const base = apiBaseURL.replace(/\/$/, '')
const res = await fetch(`${base}/admin/gallery/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
})
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
if (!res.ok) {
if (res.status === 413) {
throw new Error(
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
)
}
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
}
if (!Array.isArray(payload.urls)) {
throw new Error('Некорректный ответ сервера')
}
return payload.urls
}
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
return data
}
+3
View File
@@ -0,0 +1,3 @@
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid'
@@ -0,0 +1,7 @@
export type GalleryImageItem = {
id: string
url: string
isResized: boolean
createdAt: string
inUse?: boolean
}
@@ -0,0 +1,100 @@
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import type { GalleryImageItem } from '../model/types'
type Props = {
items: GalleryImageItem[]
deleting?: boolean
resizing?: string | null
onDelete: (id: string) => void
onResize: (id: string) => void
}
export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 2,
}}
>
{items.map((item) => (
<Box
key={item.id}
sx={{
position: 'relative',
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
aspectRatio: '1',
}}
>
<OptimizedImage
src={item.url}
alt=""
sizes="140px"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
{item.isResized ? (
<Chip
label="Готово"
size="small"
color="success"
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
/>
) : (
<Chip
label="Не обработано"
size="small"
color="warning"
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
/>
)}
</Box>
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
{!item.isResized && (
<Tooltip title="Обработать (resize)">
<IconButton
size="small"
color="primary"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
}}
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
<AutoFixHighOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Удалить из галереи">
<IconButton
size="small"
color="error"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
}}
disabled={deleting}
onClick={() => onDelete(item.id)}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
))}
</Box>
)
}
@@ -0,0 +1,54 @@
import { apiClient } from '@/shared/api/client'
export interface UserNotificationSettings {
id: string
userId: string
globalEnabled: boolean
orderCreated: boolean
orderStatusChanged: boolean
orderMessageReceived: boolean
paymentStatusChanged: boolean
deliveryFeeAdjusted: boolean
createdAt: string
updatedAt: string
}
export interface AdminNotificationSettings {
id: string
emailEnabled: boolean
telegramEnabled: boolean
telegramChatId: string | null
newOrder: boolean
newOrderMessage: boolean
newReview: boolean
authCodeDuplicate: boolean
createdAt: string
updatedAt: string
}
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
return data
}
export async function updateUserNotificationSettings(
settings: Partial<UserNotificationSettings>,
): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
return data
}
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
return data
}
export async function updateAdminNotificationSettings(
settings: Partial<AdminNotificationSettings>,
): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
'admin/notifications/settings',
settings,
)
return data
}
@@ -0,0 +1,7 @@
export {
fetchUserNotificationSettings,
updateUserNotificationSettings,
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from './api/notifications-api'
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
@@ -0,0 +1,96 @@
import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryFeeLocked: boolean
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
totalCents: number
currency: string
createdAt: string
updatedAt: string
user: { id: string; email: string }
itemsCount: number
}
export type AdminOrdersListResponse = {
items: AdminOrderListItem[]
total: number
page: number
pageSize: number
}
export type AdminOrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
deliveryFeeLocked: boolean
totalCents: number
currency: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
user: {
id: string
email: string
displayName: string | null
avatar?: string | null
avatarStyle?: string | null
}
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: string
text: string
attachmentUrl?: string | null
createdAt: string
}>
}
}
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
return data
}
export async function fetchAdminOrders(params?: {
status?: string
deliveryType?: 'delivery' | 'pickup'
q?: string
page?: number
pageSize?: number
}): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
return data
}
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
return data
}
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
await apiClient.patch(`admin/orders/${id}/status`, { status })
}
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
}
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text })
}
+103
View File
@@ -0,0 +1,103 @@
import { apiClient } from '@/shared/api/client'
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
export type OrderListItem = {
id: string
status: string
totalCents: number
currency: string
createdAt: string
updatedAt: string
itemsCount: number
}
export type OrderListResponse = { items: OrderListItem[] }
export type OrderPaymentMethod = 'online' | 'on_pickup'
export type OrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number
deliveryFeeCents: number
deliveryFeeLocked: boolean
totalCents: number
currency: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: 'user' | 'admin'
text: string
attachmentUrl?: string | null
createdAt: string
}>
}
}
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
addressId?: string | null
comment?: string | null
}): Promise<{ orderId: string }> {
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data
}
export async function fetchMyOrders(): Promise<OrderListResponse> {
const { data } = await apiClient.get<OrderListResponse>('me/orders')
return data
}
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
return data
}
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
return data
}
/** Получить статус платежа для заказа. */
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
return data
}
export async function postOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`me/orders/${id}/messages`, { text })
}
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
return data
}
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
canReview: boolean
items: ReviewEligibilityItem[]
}> {
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
`me/orders/${orderId}/review-eligibility`,
)
return data
}
+9
View File
@@ -0,0 +1,9 @@
export {
fetchMyOrders,
createOrder,
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
} from './api/order-api'
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
@@ -0,0 +1,108 @@
import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products')
return data
}
export async function createProduct(body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
quantity: number
materials?: string[]
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
categoryId: string
}): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body)
return data
}
export async function updateProduct(
id: string,
body: Partial<{
title: string
slug: string
shortDescription: string | null
description: string | null
quantity: number
materials: string[]
priceCents: number
imageUrl: string | null
imageUrls: string[]
published: boolean
categoryId: string
}>,
): Promise<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
export async function fetchAdminCategories(): Promise<Category[]> {
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
return data.items
}
export async function updateAdminCategory(
id: string,
body: Partial<{ name: string; slug: string; sort: number }>,
): Promise<Category> {
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
return data
}
export async function deleteAdminCategory(id: string): Promise<void> {
await apiClient.delete(`admin/categories/${id}`)
}
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
const list = Array.from(files)
for (const f of list) {
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
throw new Error(
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
)
}
}
const fd = new FormData()
for (const f of list) {
fd.append('files', f, f.name)
}
const token = localStorage.getItem('craftshop_auth_token')
const base = apiBaseURL.replace(/\/$/, '')
const res = await fetch(`${base}/admin/uploads`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
})
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
if (!res.ok) {
if (res.status === 413) {
throw new Error(
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
)
}
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
}
if (!Array.isArray(payload.urls)) {
throw new Error('Некорректный ответ сервера')
}
return payload.urls
}
@@ -0,0 +1,42 @@
import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
export type PublicProductsResponse = {
items: Product[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProducts(params?: {
categorySlug?: string
q?: string
sort?: 'price_asc' | 'price_desc' | ''
page?: number
pageSize?: number
priceMinCents?: number
priceMaxCents?: number
}): Promise<PublicProductsResponse> {
const { data } = await apiClient.get<PublicProductsResponse>('products', {
params: {
categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined,
sort: params?.sort || undefined,
page: params?.page || undefined,
pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? undefined,
priceMax: params?.priceMaxCents ?? undefined,
},
})
return data
}
export async function fetchPublicProduct(id: string): Promise<Product> {
const { data } = await apiClient.get<Product>(`products/${id}`)
return data
}
export async function fetchCategories(): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>('categories')
return data
}
+2
View File
@@ -0,0 +1,2 @@
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
export type { PublicProductsResponse } from './api/product-api'
@@ -0,0 +1,33 @@
export type Category = {
id: string
name: string
slug: string
sort: number
}
export type ProductReviewsSummary = {
approvedReviewCount: number
avgRating: number | null
latestApprovedText: string | null
}
export type Product = {
id: string
title: string
slug: string
shortDescription: string | null
description: string | null
quantity: number
materials?: string[]
priceCents: number
imageUrl: string | null
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
published: boolean
categoryId: string
createdAt: string
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
/** Для опубликованных товаров с публичного API. */
reviewsSummary?: ProductReviewsSummary | null
}
@@ -0,0 +1,264 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useMediaQuery } from '@mui/material'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useNavigate } from 'react-router-dom'
import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:600px)')
const swiperRef = useRef<SwiperType | null>(null)
const imageUrls = useMemo(() => {
const fromImages = (product.images ?? [])
.slice()
.sort((a, b) => a.sort - b.sort)
.map((x) => x.url)
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
return urls
}, [product.images, product.imageUrl])
const materials = (product.materials ?? []).slice(0, 3)
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
if (!swiperRef.current) return
if (imageUrls.length <= 1) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
swiperRef.current.slideTo(idx, 0)
}
const goToProduct = useCallback(() => {
navigate(`/products/${product.id}`)
}, [navigate, product.id])
const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const }
return (
<Card
onClick={goToProduct}
sx={{
cursor: 'pointer',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: '16px 16px 12px 12px',
border: 'none',
bgcolor: 'background.paper',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'transform 250ms ease, box-shadow 300ms ease',
'&:hover': {
transform: 'translateY(-6px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
},
'&:hover .product-card__media': { transform: 'scale(1.06)' },
'&:hover .product-card__title': { color: 'primary.main' },
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:hover': { transform: 'none' },
'&:hover .product-card__media': { transform: 'none' },
},
}}
>
<Box sx={{ position: 'relative' }}>
{imageUrls.length ? (
<Box
onMouseMove={!isMobile ? onMouseMove : undefined}
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
>
<Swiper
slidesPerView={1}
spaceBetween={16}
allowTouchMove={!isMobile}
onSwiper={(s) => {
swiperRef.current = s
}}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
<Box
className="product-card__media"
sx={{
width: '100%',
height: '100%',
transition: 'transform 320ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
userSelect: 'none',
bgcolor: 'grey.50',
}}
>
<OptimizedImage
src={url}
alt={product.title}
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
sx={{
width: '101%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
</SwiperSlide>
))}
</Swiper>
</Box>
) : (
<CardMedia
component="div"
sx={{
width: '100%',
aspectRatio: '3/4',
maxHeight: mediaHeight,
bgcolor: 'grey.50',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography color="text.disabled" variant="body2">
Нет фото
</Typography>
</CardMedia>
)}
{stockLabel && (
<Chip
label={stockLabel.label}
size="small"
color={stockLabel.color}
variant="filled"
sx={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 2,
fontWeight: 600,
fontSize: '0.7rem',
backdropFilter: 'blur(4px)',
bgcolor: 'rgba(0,0,0,0.55)',
color: 'common.white',
}}
/>
)}
</Box>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
{product.category && (
<Chip
label={product.category.name}
size="small"
color="primary"
sx={{
alignSelf: 'flex-start',
fontWeight: 600,
fontSize: '0.65rem',
height: 22,
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
/>
)}
<Typography
variant="subtitle1"
component="h2"
className="product-card__title"
sx={{
textDecoration: 'none',
color: 'text.primary',
fontWeight: 600,
lineHeight: 1.3,
transition: 'color 150ms ease',
}}
>
{product.title}
</Typography>
{(product.materials?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
{materials.map((m) => (
<Chip
key={m}
label={m}
size="small"
variant="outlined"
sx={{
bgcolor: 'chip.default',
color: 'text.secondary',
fontSize: '0.7rem',
height: 22,
fontWeight: 500,
}}
/>
))}
{moreMaterials > 0 && (
<Chip
label={`+${moreMaterials}`}
size="small"
variant="outlined"
sx={{
bgcolor: 'chip.default',
color: 'text.secondary',
fontSize: '0.7rem',
height: 22,
fontWeight: 500,
}}
/>
)}
</Stack>
)}
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
WebkitLineClamp: 2,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
fontSize: '0.8125rem',
lineHeight: 1.45,
}}
>
{product.shortDescription ?? 'Описание появится позже.'}
</Typography>
</Stack>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
<Typography
variant="h6"
component="p"
color="primary"
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
>
{formatPriceRub(product.priceCents)}
</Typography>
{actions}
</Box>
</Box>
</Card>
)
}
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
})
@@ -0,0 +1,32 @@
import { apiClient } from '@/shared/api/client'
export type AdminReview = {
id: string
rating: number
text: string | null
status: string
createdAt: string
moderatedAt: string | null
user: { id: string; email: string; displayName: string | null }
product: { id: string; title: string }
}
export type AdminReviewsListResponse = {
items: AdminReview[]
total: number
page: number
pageSize: number
}
export async function fetchAdminReviews(params?: {
status?: string
page?: number
pageSize?: number
}): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
return data
}
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(`admin/reviews/${id}`, { action })
}
@@ -0,0 +1,78 @@
import { apiClient } from '@/shared/api/client'
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
export async function postProductReview(
productId: string,
body: { rating: number; text?: string | null; imageUrl?: string | null },
): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body)
}
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
throw new Error(`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`)
}
const fd = new FormData()
fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
return data
}
export type PublicReviewFeedItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
product: {
id: string
title: string
published: boolean
slug: string
}
}
export type PublicReviewsLatestResponse = {
items: PublicReviewFeedItem[]
}
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
params: { limit },
})
return data
}
export type PublicProductReviewItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
}
export type PublicProductReviewsResponse = {
items: PublicProductReviewItem[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProductReviews(
productId: string,
params?: { page?: number; pageSize?: number },
): Promise<PublicProductReviewsResponse> {
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
params: { page: params?.page, pageSize: params?.pageSize },
})
return data
}
+12
View File
@@ -0,0 +1,12 @@
export {
postProductReview,
uploadReviewImage,
fetchLatestApprovedReviews,
fetchPublicProductReviews,
} from './api/reviews-api'
export type {
PublicReviewFeedItem,
PublicReviewsLatestResponse,
PublicProductReviewItem,
PublicProductReviewsResponse,
} from './api/reviews-api'
@@ -0,0 +1,40 @@
import { apiClient } from '@/shared/api/client'
export type ChecklistResultDto = {
passed: boolean
comment: string | null
checkedAt: string
}
export type TestChecklistResponse = {
results: Record<string, ChecklistResultDto>
}
export type UpdateChecklistItemResponse = {
itemKey: string
passed: boolean
comment: string | null
checkedAt: string
}
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
return data
}
export async function updateTestChecklistItem(
itemKey: string,
passed: boolean,
comment?: string | null,
): Promise<UpdateChecklistItemResponse> {
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
itemKey,
passed,
comment: passed ? null : (comment ?? null),
})
return data.result
}
export async function resetTestChecklist(): Promise<void> {
await apiClient.post('admin/test-checklist/reset')
}
@@ -0,0 +1,49 @@
import type { ShippingAddress } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AddressesListResponse = { items: ShippingAddress[] }
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
return data
}
export async function createMyAddress(body: {
label?: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment?: string | null
lat: number
lng: number
isDefault?: boolean
}): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
return data
}
export async function updateMyAddress(
id: string,
body: Partial<{
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
}>,
): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
return data
}
export async function deleteMyAddress(id: string): Promise<void> {
await apiClient.delete(`me/addresses/${id}`)
}
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
return data
}
@@ -0,0 +1,24 @@
import { apiClient } from '@/shared/api/client'
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
return data
}
export async function markOrderMessagesRead(orderId: string): Promise<void> {
await apiClient.post(`me/orders/${orderId}/messages/read`)
}
export type ConversationSummary = {
orderId: string
status: string
deliveryType: 'delivery' | 'pickup'
lastMessageAt: string
preview: string
unreadCount: number
}
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
return data
}
+45
View File
@@ -0,0 +1,45 @@
import type { AdminUser } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AdminUsersListResponse = {
items: AdminUser[]
total: number
page: number
pageSize: number
}
export async function fetchAdminUsers(params?: {
q?: string
page?: number
pageSize?: number
}): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
return data
}
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body)
return data
}
export async function updateAdminUser(
id: string,
body: Partial<{ email: string; displayName: string | null }>,
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
return data
}
export type AdminAvatarResponse = {
avatar: string | null
avatarStyle: string | null
}
export async function fetchAdminAvatar(): Promise<AdminAvatarResponse> {
const { data } = await apiClient.get<AdminAvatarResponse>('admin/avatar')
return data
}
export async function deleteAdminUser(id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`)
}
+10
View File
@@ -0,0 +1,10 @@
export type { AdminUser, ShippingAddress } from './model/types'
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
export type { AdminUsersListResponse } from './api/user-api'
export {
fetchMyAddresses,
createMyAddress,
updateMyAddress,
deleteMyAddress,
setMyAddressDefault,
} from './api/address-api'
+23
View File
@@ -0,0 +1,23 @@
export type AdminUser = {
id: string
email: string
displayName: string | null
avatar?: string | null
avatarStyle?: string | null
createdAt: string
updatedAt: string
}
export type ShippingAddress = {
id: string
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
createdAt: string
updatedAt: string
}
@@ -0,0 +1,2 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
@@ -0,0 +1,127 @@
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 FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import { Controller, type UseFormReturn } from 'react-hook-form'
import { AddressMapPicker } from '@/features/address-map-picker'
export type AddressFormValues = {
label: string
recipientName: string
recipientPhone: string
addressLine: string
comment: string
lat: number | null
lng: number | null
isDefault: boolean
}
export function AddressFormDialog({
open,
onClose,
editing,
form,
onSubmit,
isPending,
}: {
open: boolean
onClose: () => void
editing: boolean
form: UseFormReturn<AddressFormValues>
onSubmit: () => void
isPending: boolean
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="label"
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Controller
control={form.control}
name="recipientName"
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="recipientPhone"
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
/>
</Stack>
<Controller
control={form.control}
name="addressLine"
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="comment"
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="lat"
render={({ field: latField }) => (
<Controller
control={form.control}
name="lng"
render={({ field: lngField }) => (
<AddressMapPicker
value={
latField.value !== null && lngField.value !== null
? { lat: latField.value, lng: lngField.value }
: null
}
onChange={(v) => {
latField.onChange(v.lat)
lngField.onChange(v.lng)
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
}}
/>
)}
/>
)}
/>
<Controller
control={form.control}
name="isDefault"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
label="Адрес по умолчанию"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={
isPending ||
!form.watch('recipientName').trim() ||
!form.watch('recipientPhone').trim() ||
!form.watch('addressLine').trim()
}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,28 @@
import type { LatLng, NominatimItem } from '../model/types'
export async function reverseGeocode(pos: LatLng): Promise<string | null> {
const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('lat', String(pos.lat))
url.searchParams.set('lon', String(pos.lng))
url.searchParams.set('accept-language', 'ru')
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
if (!res.ok) return null
const data = (await res.json()) as { display_name?: string }
return data.display_name ? String(data.display_name) : null
}
export async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
const url = new URL('https://nominatim.openstreetmap.org/search')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('q', q)
url.searchParams.set('accept-language', 'ru')
url.searchParams.set('limit', '5')
const res = await fetch(url.toString(), {
headers: { 'User-Agent': 'craftshop-demo' },
signal,
})
if (!res.ok) return []
const data = (await res.json()) as NominatimItem[]
return Array.isArray(data) ? data : []
}
@@ -0,0 +1 @@
export { AddressMapPicker } from './ui/AddressMapPicker'
@@ -0,0 +1,3 @@
export type NominatimItem = { display_name: string; lat: string; lon: string }
export type LatLng = { lat: number; lng: number }
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
import { MapPickerMap } from './MapPickerMap'
import type { LatLng, NominatimItem } from '../model/types'
export function AddressMapPicker(props: {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) {
const { value, onChange } = props
const [q, setQ] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<NominatimItem[]>([])
const [hint, setHint] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const lastQueryRef = useRef<string>('')
const lastRequestAtRef = useRef<number>(0)
const qTrimmed = q.trim()
const visibleResults = qTrimmed.length >= 3 ? results : []
const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 }
}, [value])
const pick = async (pos: LatLng) => {
setHint(null)
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch (err) {
console.warn('[address-map-picker] Failed to reverse geocode', err)
}
}
useEffect(() => {
const s = qTrimmed
if (s.length < 3) {
return
}
const t = window.setTimeout(async () => {
const now = Date.now()
if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return
lastQueryRef.current = s
lastRequestAtRef.current = now
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
setResults(await searchPlaces(s, ac.signal))
} catch (e) {
if ((e as { name?: string })?.name !== 'AbortError') {
setResults([])
}
} finally {
setSearching(false)
}
}, 450)
return () => {
window.clearTimeout(t)
}
}, [qTrimmed])
return (
<Stack spacing={1.5}>
<Typography variant="subtitle2">Выбор на карте</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
<Button
variant="outlined"
onClick={async () => {
const s = q.trim()
if (!s) return
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
lastQueryRef.current = s
lastRequestAtRef.current = Date.now()
setResults(await searchPlaces(s, ac.signal))
} finally {
setSearching(false)
}
}}
disabled={searching || !q.trim()}
sx={{ minWidth: 160 }}
>
{searching ? <CircularProgress size={18} /> : 'Найти'}
</Button>
</Stack>
{visibleResults.length > 0 && (
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
{visibleResults.map((r) => (
<ListItemButton
key={`${r.lat}:${r.lon}:${r.display_name}`}
onClick={() => {
const lat = Number(r.lat)
const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
void pick({ lat, lng })
}}
>
<ListItemText primary={r.display_name} />
</ListItemButton>
))}
</List>
)}
<MapPickerMap value={value} onChange={onChange} center={center} />
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{hint && (
<Typography variant="caption" color="text.secondary">
Подсказка адреса: {hint}
</Typography>
)}
</Box>
</Stack>
)
}

Some files were not shown because too many files have changed in this diff Show More