Compare commits
3 Commits
6aef5fb7ce
...
41c1385546
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c1385546 | ||
|
|
9974d41bd8 | ||
|
|
0a56297ad7 |
@ -3,6 +3,8 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
use context7 always for docs frameworks
|
||||
|
||||
Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд).
|
||||
|
||||
Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст.
|
||||
|
||||
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY backend/ .
|
||||
|
||||
# Копирование .env.docker в .env для использования в контейнере
|
||||
COPY backend/.env.docker ./app/.env
|
||||
COPY backend/.env.docker ./.env
|
||||
|
||||
# Создание директории для загрузок если её нет
|
||||
RUN mkdir -p /app/uploads/products
|
||||
@ -25,4 +25,4 @@ RUN chmod -R 777 /app/uploads
|
||||
EXPOSE 8000
|
||||
|
||||
# Запуск приложения с Uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@ -8,12 +8,12 @@ COPY frontend/package*.json ./
|
||||
# Установка зависимостей с флагом --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY frontend/ ./
|
||||
|
||||
# Копирование .env.docker в .env.local для использования в контейнере
|
||||
COPY frontend/.env.docker ./.env.local
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY frontend/ ./
|
||||
|
||||
# Сборка приложения
|
||||
RUN npm run build
|
||||
|
||||
@ -21,4 +21,4 @@ RUN npm run build
|
||||
EXPOSE 3000
|
||||
|
||||
# Запуск приложения
|
||||
CMD ["npm", "start"]
|
||||
CMD ["sh", "-c", "npm run build && npm start"]
|
||||
56
Logo DRESSED FOR SUCCESS/DFS_chik&active.svg
Normal file
@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2021.5 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="101.681mm" height="52.7148mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 9795.77 5078.45"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_105553243841888">
|
||||
<path class="fil0" d="M4908.27 805.19c54.07,19.97 154.75,32.69 273.42,7.79 134.29,-28.17 227.42,-126.87 210.47,-234.42 -15.58,-98.88 -78.5,-144.79 -183.21,-184.87 -5,-103.57 -48.42,-197.68 -117.48,-266.17 -25.62,-25.61 -54.57,-47.33 -86.3,-65.14 -19.36,-12.43 -50.13,-24.51 -52.91,-25.62 30.07,-7.24 62.93,-11.37 101.34,-12.25 160.91,-3.71 233.25,103.02 241.68,154.81l67.38 1.11c-5.58,-28.4 -59.6,-180.41 -318.52,-180.41 -47.89,0 -94.65,8.35 -136.41,22.27 -33.41,-8.91 -66.83,-13.92 -102.48,-13.92 -101.33,0 -403.71,0.56 -403.71,0.56l0 17.25c35.1,0 57.37,8.36 71.83,20.61 26.18,21.17 26.18,55.13 26.18,74.61 0,145.76 0,437.26 0,583.01 0,22.83 0,67.94 -45.67,86.87 -12.8,5.57 -30.08,9.47 -52.34,9.47l0 17.81c0,0 245.13,0 396.09,0 61.7,0 110.63,-13.36 110.63,-13.36zm-333.56 -11.14l0 0 0 -760.64 180.43 0c66.83,0 127.42,11.35 204.92,62.92 94.34,62.78 161.59,156.48 172.05,270.63 16.65,181.55 -83.66,312.64 -175.39,368.06 23.38,10.03 47.31,17.26 62.92,21.17 96.9,-61.25 165.06,-170.19 183.75,-279.54 84.09,39.54 123.05,81.3 129.2,142.55 5,52.9 -21.16,95.78 -63.49,126.95 -57.91,43.44 -148.19,64.63 -225.51,56.8 -162.62,-16.47 -250.22,-113.71 -270.64,-191.55l-70.14 0c12.85,56.55 58.58,124.7 147.55,169.83 0,0 -38.54,12.8 -97.45,12.8 -44.55,0 -178.2,0 -178.2,0z"/>
|
||||
<path class="fil0" d="M5019.09 410.39c23.38,7.24 44.54,13.92 64.58,20.6 1.12,-21.71 1.69,-51.78 -3.34,-80.74 -187.66,-59.58 -275.07,-87.98 -273.39,-189.32 0.55,-23.94 12.23,-47.33 34.5,-68.49 -13.34,-5.01 -27.84,-9.47 -41.18,-12.25 -42.5,35.61 -54.6,75.9 -54.57,107.47 0.11,118.63 90.75,168.16 273.41,222.73z"/>
|
||||
</g>
|
||||
<g id="_105553243834656">
|
||||
<g>
|
||||
<path class="fil0" d="M2921.05 1914.15c14.17,-25.91 21.24,-57.16 21.24,-93.77 0,-54.25 -15.78,-95.9 -47.37,-124.97 -31.56,-29.06 -76.18,-43.59 -133.82,-43.59l-123.88 0 0 342.24 138.46 0c32.71,0 61.58,-6.84 86.58,-20.54 25.03,-13.67 44.62,-33.47 58.8,-59.37zm-98.13 10.09l0 0c-14.98,9.62 -32.76,14.44 -53.33,14.44l-60.71 0 0 -231.48 50.76 0c35.47,0 62.75,9.68 81.85,29.03 19.12,19.35 28.68,47.4 28.68,84.16 0,24.29 -4.13,45.26 -12.4,62.91 -8.25,17.66 -19.87,31.3 -34.85,40.94z"/>
|
||||
<path class="fil0" d="M3167.62 1864.11l84.03 0 79.44 129.96 80.64 0 -92.53 -143.8c23.15,-5.49 41.54,-16.96 55.14,-34.36 13.59,-17.4 20.39,-38.1 20.39,-62.06 0,-32.87 -11.09,-58.09 -33.28,-75.67 -22.19,-17.56 -53.68,-26.34 -94.49,-26.34l-171 0 0 342.24 71.67 0 0 -129.96zm0 -156.67l0 0 91.8 0c21.07,0 36.85,4.12 47.37,12.39 10.53,8.26 15.79,20.57 15.79,36.93 0,16.35 -5.17,29.06 -15.55,38.13 -10.36,9.08 -25.57,13.6 -45.66,13.6l-93.75 0 0 -101.05z"/>
|
||||
<polygon class="fil0" points="3833.78,1938.68 3626.36,1938.68 3626.36,1848.56 3809.02,1848.56 3809.02,1793.18 3626.36,1793.18 3626.36,1707.2 3823.83,1707.2 3823.83,1651.83 3554.7,1651.83 3554.7,1994.07 3833.78,1994.07 "/>
|
||||
<path class="fil0" d="M4239.57 1829.39c-4.06,-3.88 -8.85,-7.53 -14.45,-10.94 -5.59,-3.39 -11.96,-6.64 -19.07,-9.72 -7.13,-2.91 -16.61,-5.93 -28.43,-9.1 -11.81,-3.16 -25.91,-6.51 -42.25,-10.07 -28.18,-5.99 -46.57,-10.94 -55.14,-14.83 -9.08,-4.04 -15.92,-8.89 -20.52,-14.58 -4.61,-5.66 -6.93,-12.95 -6.93,-21.85 0,-13.6 5.42,-23.81 16.28,-30.62 10.84,-6.8 27.11,-10.19 48.81,-10.19 20.4,0 36.08,3.72 47,11.17 10.92,7.46 18.17,18.55 21.74,33.28l69.46 -9.47c-6.16,-30.62 -19.87,-52.56 -41.17,-65.84 -21.28,-13.27 -53.14,-19.9 -95.58,-19.9 -44.36,0 -78.17,8.21 -101.4,24.63 -23.25,16.44 -34.86,40.13 -34.86,71.06 0,18.13 3.4,33.27 10.2,45.42 6.8,12.14 16.26,21.85 28.42,29.15 5.5,3.24 11.17,6.19 16.99,8.85 5.83,2.68 13.6,5.42 23.31,8.27 9.72,2.84 23.18,6.27 40.34,10.32 14.9,3.08 27.37,5.87 37.4,8.38 10.04,2.51 17.65,4.82 22.84,6.93 10.52,4.22 18.62,9.48 24.29,15.79 5.66,6.32 8.51,14.34 8.51,24.05 0,15.22 -6.23,26.64 -18.72,34.25 -12.47,7.6 -31.24,11.42 -56.36,11.42 -24.12,0 -42.99,-4.08 -56.59,-12.26 -13.6,-8.17 -22.75,-21.34 -27.44,-39.47l-69.23 11.41c14.25,62.68 64.7,94.01 151.32,94.01 48.1,0 84.59,-8.87 109.44,-26.6 24.84,-17.73 37.26,-43.36 37.26,-76.87 0,-14.26 -2.18,-26.86 -6.54,-37.78 -4.38,-10.92 -10.68,-20.37 -18.95,-28.28z"/>
|
||||
<path class="fil0" d="M4549.65 1998.92c48.1,0 84.59,-8.87 109.44,-26.6 24.84,-17.73 37.27,-43.36 37.27,-76.87 0,-14.26 -2.19,-26.86 -6.55,-37.78 -4.38,-10.92 -10.68,-20.37 -18.95,-28.28 -4.06,-3.88 -8.85,-7.53 -14.45,-10.94 -5.59,-3.39 -11.96,-6.64 -19.07,-9.72 -7.13,-2.91 -16.61,-5.93 -28.43,-9.1 -11.81,-3.16 -25.91,-6.51 -42.25,-10.07 -28.18,-5.99 -46.57,-10.94 -55.14,-14.83 -9.08,-4.04 -15.92,-8.89 -20.52,-14.58 -4.61,-5.66 -6.93,-12.95 -6.93,-21.85 0,-13.6 5.42,-23.81 16.28,-30.62 10.84,-6.8 27.11,-10.19 48.81,-10.19 20.4,0 36.08,3.72 47,11.17 10.92,7.46 18.17,18.55 21.74,33.28l69.46 -9.47c-6.16,-30.62 -19.87,-52.56 -41.17,-65.84 -21.28,-13.27 -53.14,-19.9 -95.58,-19.9 -44.36,0 -78.17,8.21 -101.4,24.63 -23.25,16.44 -34.86,40.13 -34.86,71.06 0,18.13 3.4,33.27 10.2,45.42 6.8,12.14 16.26,21.85 28.42,29.15 5.5,3.24 11.17,6.19 16.99,8.85 5.83,2.68 13.6,5.42 23.31,8.27 9.72,2.84 23.18,6.27 40.34,10.32 14.9,3.08 27.37,5.87 37.4,8.38 10.04,2.51 17.65,4.82 22.84,6.93 10.52,4.22 18.62,9.48 24.29,15.79 5.66,6.32 8.51,14.34 8.51,24.05 0,15.22 -6.23,26.64 -18.72,34.25 -12.47,7.6 -31.24,11.42 -56.36,11.42 -24.12,0 -42.99,-4.08 -56.59,-12.26 -13.6,-8.17 -22.75,-21.34 -27.44,-39.47l-69.23 11.41c14.25,62.68 64.7,94.01 151.32,94.01z"/>
|
||||
<polygon class="fil0" points="5117.7,1707.2 5117.7,1651.83 4848.57,1651.83 4848.57,1994.07 5127.66,1994.07 5127.66,1938.68 4920.23,1938.68 4920.23,1848.56 5102.89,1848.56 5102.89,1793.18 4920.23,1793.18 4920.23,1707.2 "/>
|
||||
<path class="fil0" d="M5279.85 1994.07l138.47 0c32.7,0 61.57,-6.84 86.58,-20.54 25.02,-13.67 44.62,-33.47 58.8,-59.37 14.16,-25.91 21.24,-57.16 21.24,-93.77 0,-54.25 -15.78,-95.9 -47.37,-124.97 -31.57,-29.06 -76.18,-43.59 -133.82,-43.59l-123.89 0 0 342.24zm71.67 -286.87l0 0 50.76 0c35.47,0 62.75,9.68 81.85,29.03 19.12,19.35 28.67,47.4 28.67,84.16 0,24.29 -4.12,45.26 -12.39,62.91 -8.26,17.66 -19.87,31.3 -34.86,40.94 -14.97,9.62 -32.76,14.44 -53.32,14.44l-60.71 0 0 -231.48z"/>
|
||||
<polygon class="fil0" points="6047.96,1868.49 6223.09,1868.49 6223.09,1813.11 6047.96,1813.11 6047.96,1707.2 6228.67,1707.2 6228.67,1651.83 5976.31,1651.83 5976.31,1994.07 6047.96,1994.07 "/>
|
||||
<path class="fil0" d="M6446.68 1977.42c25.76,14.34 56.52,21.5 92.3,21.5 34.98,0 65.47,-7.37 91.46,-22.11 25.99,-14.72 46.14,-35.41 60.48,-62.05 14.34,-26.64 21.5,-57.76 21.5,-93.4 0,-36.26 -6.9,-67.45 -20.66,-93.52 -13.75,-26.07 -33.51,-46.11 -59.27,-60.12 -25.74,-14 -56.75,-21 -93.01,-21 -36.12,0 -67.1,6.91 -92.91,20.75 -25.83,13.85 -45.59,33.81 -59.27,59.89 -13.69,26.06 -20.53,57.39 -20.53,93.99 0,36.61 6.87,68.17 20.64,94.74 13.78,26.56 33.54,47 59.27,61.32zm18.47 -243.14l0 0c17.49,-20.81 42.27,-31.2 74.33,-31.2 31.57,0 56.1,10.49 73.58,31.44 17.49,20.97 26.23,49.91 26.23,86.84 0,38.71 -8.69,68.57 -26.11,89.64 -17.41,21.04 -42.14,31.56 -74.21,31.56 -20.57,0 -38.32,-4.89 -53.3,-14.7 -14.98,-9.79 -26.53,-23.76 -34.61,-41.89 -8.1,-18.13 -12.15,-39.67 -12.15,-64.61 0,-37.23 8.74,-66.27 26.23,-87.08z"/>
|
||||
<path class="fil0" d="M6937.74 1864.11l84.04 0 79.43 129.96 80.64 0 -92.54 -143.8c23.15,-5.49 41.54,-16.96 55.14,-34.36 13.6,-17.4 20.4,-38.1 20.4,-62.06 0,-32.87 -11.1,-58.09 -33.29,-75.67 -22.18,-17.56 -53.68,-26.34 -94.48,-26.34l-171 0 0 342.24 71.66 0 0 -129.96zm0 -156.67l0 0 91.81 0c21.06,0 36.84,4.12 47.37,12.39 10.52,8.26 15.78,20.57 15.78,36.93 0,16.35 -5.17,29.06 -15.55,38.13 -10.36,9.08 -25.57,13.6 -45.66,13.6l-93.75 0 0 -101.05z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="fil0" d="M627.35 3128.5c-312.41,-99.91 -458.08,-146.06 -455.14,-313.6 1.93,-109.46 154.21,-225.53 405.68,-225.47 287.79,0.07 385.03,181.46 397.9,255.57l111.56 1.45c-9.62,-46.47 -97.83,-297.67 -524.58,-297.67 -259.34,0 -495.04,135.69 -491.46,309.29 4.01,196.05 149.13,276.7 449.94,366.97 355.51,106.69 500.8,187.23 515.93,345.01 19.96,208.26 -256.33,323.28 -475.22,301.64 -358.92,-35.49 -444.44,-293.58 -445.96,-314.95l-115.99 0c39.03,195.14 288.44,356.1 562.16,356.1 379.65,0 604.74,-178.57 573.21,-410.47 -30.07,-221.31 -214.33,-279.92 -508.03,-373.87z"/>
|
||||
<path class="fil0" d="M9792.83 3502.37c-30.09,-221.31 -214.33,-279.92 -508.03,-373.87 -312.41,-99.91 -458.08,-146.06 -455.14,-313.6 1.9,-109.46 154.21,-225.53 405.68,-225.47 287.79,0.07 385,181.46 397.9,255.57l111.56 1.45c-9.62,-46.47 -97.83,-297.67 -524.58,-297.67 -259.34,0 -495.04,135.69 -491.46,309.29 4.01,196.05 149.13,276.7 449.95,366.97 355.48,106.69 500.8,187.23 515.92,345.01 19.96,208.26 -256.33,323.28 -475.22,301.64 -358.92,-35.49 -444.44,-293.58 -445.96,-314.95l-116.01 0c39.05,195.14 288.46,356.1 562.18,356.1 379.65,0 604.74,-178.57 573.21,-410.47z"/>
|
||||
<path class="fil0" d="M5602.13 3400.62c-63.13,271.92 -276.29,471.98 -529.27,471.98 -303.48,0 -549.2,-287.46 -549.2,-641.93 0,-354.48 245.71,-641.94 549.2,-641.94 253.46,0 466.14,200.06 529.27,472.47l132.07 0c-75.26,-294.75 -343.3,-512.77 -661.34,-512.77 -376.82,0 -682.73,305.43 -682.73,682.24 0,377.29 305.91,682.72 682.73,682.72 318.04,0 585.59,-217.54 661.34,-512.77l-132.07 0z"/>
|
||||
<path class="fil0" d="M7158.78 3127.72l54.39 0 78.2 0c-49.56,-327.76 -332.66,-579.29 -674.5,-579.29 -377.27,0 -682.7,305.43 -682.7,682.24 0,377.29 305.43,682.72 682.7,682.72 258.81,0 483.69,-143.73 599.24,-356.41l-126.28 0c-95.64,188.89 -271.91,315.62 -472.96,315.62 -303.48,0 -549.64,-287.46 -549.64,-641.93 0,-34.96 2.42,-69.44 7.25,-102.95l1084.3 0zm-541.91 -538.99l0 0c261.28,0 480.27,213.66 535.61,499.67l-1071.65 0c55.35,-286.01 274.32,-499.67 536.04,-499.67z"/>
|
||||
<path class="fil0" d="M8004.3 3128.5c-312.38,-99.91 -458.08,-146.06 -455.14,-313.6 1.93,-109.46 154.21,-225.53 405.7,-225.47 287.79,0.07 385.01,181.46 397.88,255.57l111.56 1.45c-9.62,-46.47 -97.8,-297.67 -524.56,-297.67 -259.33,0 -495.03,135.69 -491.48,309.29 4.03,196.05 149.13,276.7 449.94,366.97 355.51,106.69 500.8,187.23 515.93,345.01 19.96,208.26 -256.3,323.28 -475.22,301.64 -358.9,-35.49 -444.45,-293.58 -445.96,-314.95l-115.99 0c39.03,195.14 288.46,356.1 562.16,356.1 379.64,0 604.74,-178.57 573.23,-410.47 -30.09,-221.31 -214.35,-279.92 -508.05,-373.87z"/>
|
||||
<path class="fil0" d="M3529.31 2588.72c252.98,0 466.16,200.06 529.28,472.47l131.59 0c-75.26,-294.75 -342.82,-512.77 -660.87,-512.77 -377.29,0 -682.72,305.43 -682.72,682.24 0,377.29 305.43,682.72 682.72,682.72 318.05,0 585.12,-217.54 660.87,-512.77l-131.59 0c-63.12,271.92 -276.77,471.98 -529.28,471.98 -303.48,0 -549.67,-287.46 -549.67,-641.93 0,-354.48 246.18,-641.94 549.67,-641.94z"/>
|
||||
<path class="fil0" d="M2453.98 2562.09l-0.19 0 0 849.19c0,254.83 -206.58,461.42 -461.41,461.42 -254.86,0 -461.44,-206.59 -461.44,-461.42l0 -849.19 -123.81 0 0 852.58c0,336.18 313.22,498.69 588.65,498.69 205.52,0 381.87,-124.37 458.21,-301.9l0 261.24 123.81 0 0 -1310.94 -123.81 0 0 0.33z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="fil0" d="M2863.3 4731.51c41.79,0 74.39,11.02 97.36,33.06 22.96,22.04 35.82,46.84 38.11,74.85l-43.17 0c-5.05,-21.13 -15.15,-38.11 -29.85,-50.51 -14.7,-12.4 -35.37,-18.37 -61.99,-18.37 -32.15,0 -58.32,11.02 -78.53,33.98 -19.75,22.97 -29.86,57.87 -29.86,104.71 0,38.57 9.19,69.8 27.1,94.13 17.91,23.88 45,35.82 80.36,35.82 33.06,0 58.32,-12.4 75.32,-37.65 9.18,-13.32 16.07,-30.77 20.2,-52.81l43.63 0c-3.68,34.9 -16.53,63.83 -38.57,87.25 -26.18,28.47 -61.54,42.71 -105.63,42.71 -38.57,0 -70.72,-11.95 -96.43,-34.9 -34.44,-30.77 -51.44,-78.06 -51.44,-141.9 0,-48.68 12.86,-88.63 38.57,-119.4 27.55,-33.98 66.13,-50.97 114.81,-50.97z"/>
|
||||
<polygon id="_1" class="fil0" points="3146.94,4740.7 3191.94,4740.7 3191.94,4876.62 3363.23,4876.62 3363.23,4740.7 3408.23,4740.7 3408.23,5069.96 3363.23,5069.96 3363.23,4916.12 3191.94,4916.12 3191.94,5069.96 3146.94,5069.96 "/>
|
||||
<polygon id="_2" class="fil0" points="3576.6,4740.7 3621.61,4740.7 3621.61,5069.96 3576.6,5069.96 "/>
|
||||
<polygon id="_3" class="fil0" points="3783.39,4740.7 3827.01,4740.7 3827.01,4900.97 3987.74,4740.7 4049.27,4740.7 3911.96,4873.42 4052.94,5069.96 3995.08,5069.96 3879.82,4904.64 3827.01,4955.15 3827.01,5069.96 3783.39,5069.96 "/>
|
||||
<path id="_4" class="fil0" d="M4492.31 4868.82c14.23,-10.11 23.87,-18.37 29.38,-24.8 8.73,-10.11 12.86,-21.13 12.86,-33.53 0,-10.11 -3.22,-18.37 -9.64,-25.26 -6.43,-6.89 -14.69,-10.56 -25.71,-10.56 -16.53,0 -28.02,5.51 -34.44,16.53 -3.68,5.51 -5.06,11.94 -5.06,18.82 0,8.73 2.3,17.46 7.35,26.18 5.05,8.28 13.31,19.3 25.26,32.61zm-10.57 173.12c16.53,0 30.77,-3.67 42.71,-11.02 11.95,-7.8 21.59,-16.53 28.02,-25.71l-74.85 -90.92c-21.12,14.24 -34.44,24.8 -40.87,32.15 -10.11,11.47 -15.15,25.25 -15.15,41.33 0,17.45 6.43,30.76 19.29,40.4 12.86,9.19 26.64,13.78 40.87,13.78zm-26.18 -155.21c-14.23,-16.07 -23.42,-29.39 -28.01,-40.41 -4.6,-11.02 -7.35,-21.58 -7.35,-31.69 0,-21.13 7.35,-38.57 21.58,-52.81 14.7,-13.78 33.53,-20.66 57.86,-20.66 22.97,0 40.41,6.44 53.28,19.29 12.85,12.86 19.29,28.48 19.29,46.84 0,21.13 -6.44,39.5 -19.75,55.12 -7.8,9.64 -20.66,20.2 -39.04,32.14l60.16 71.64c4.13,-11.94 6.89,-20.66 8.27,-26.63 1.83,-5.97 3.21,-14.24 5.05,-24.8l38.12 0c-2.3,21.12 -7.35,41.33 -15.15,60.62 -7.81,19.29 -11.48,27.09 -11.48,23.42l58.78 71.17 -52.35 0 -30.77 -37.66c-12.4,13.32 -23.42,22.97 -33.52,28.93 -17.91,11.02 -38.57,16.53 -61.54,16.53 -34.44,0 -59.24,-9.18 -74.85,-28.01 -15.62,-18.37 -23.42,-39.04 -23.42,-62.46 0,-24.8 7.8,-45.46 22.96,-62.46 9.18,-10.1 26.64,-22.96 51.89,-38.11z"/>
|
||||
<path id="_5" class="fil0" d="M5152.26 4934.95l-50.06 -145.58 -52.8 145.58 102.86 0zm-73.01 -194.25l50.51 0 119.4 329.26 -49.13 0 -33.07 -98.73 -130.41 0 -35.82 98.73 -45.46 0 123.99 -329.26z"/>
|
||||
<path id="_6" class="fil0" d="M5517.55 4731.51c41.79,0 74.39,11.02 97.36,33.06 22.96,22.04 35.82,46.84 38.11,74.85l-43.17 0c-5.05,-21.13 -15.15,-38.11 -29.85,-50.51 -14.7,-12.4 -35.37,-18.37 -61.99,-18.37 -32.15,0 -58.32,11.02 -78.53,33.98 -19.75,22.97 -29.86,57.87 -29.86,104.71 0,38.57 9.19,69.8 27.1,94.13 17.91,23.88 45,35.82 80.36,35.82 33.06,0 58.32,-12.4 75.32,-37.65 9.18,-13.32 16.07,-30.77 20.2,-52.81l43.63 0c-3.68,34.9 -16.53,63.83 -38.57,87.25 -26.18,28.47 -61.54,42.71 -105.63,42.71 -38.57,0 -70.72,-11.95 -96.43,-34.9 -34.44,-30.77 -51.44,-78.06 -51.44,-141.9 0,-48.68 12.86,-88.63 38.57,-119.4 27.55,-33.98 66.13,-50.97 114.81,-50.97z"/>
|
||||
<polygon id="_7" class="fil0" points="6039.52,4740.7 6039.52,4779.73 5928.39,4779.73 5928.39,5069.96 5883.39,5069.96 5883.39,4779.73 5772.26,4779.73 5772.26,4740.7 "/>
|
||||
<polygon id="_8" class="fil0" points="6179.72,4740.7 6224.73,4740.7 6224.73,5069.96 6179.72,5069.96 "/>
|
||||
<polygon id="_9" class="fil0" points="6412.69,4740.7 6507.28,5021.28 6600.51,4740.7 6650.56,4740.7 6530.7,5069.96 6483.4,5069.96 6363.09,4740.7 "/>
|
||||
<polygon id="_10" class="fil0" points="6786.25,4740.7 7026.42,4740.7 7026.42,4781.1 6829.87,4781.1 6829.87,4880.76 7011.72,4880.76 7011.72,4918.88 6829.87,4918.88 6829.87,5030.92 7030.09,5030.92 7030.09,5069.96 6786.25,5069.96 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
55
Logo DRESSED FOR SUCCESS/DFS_home&sleep.svg
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2021.5 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="101.681mm" height="52.664mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 9785.87 5068.43"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#2B2B2A;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_105553243821280">
|
||||
<path class="fil0" d="M4903.31 804.37c54.01,19.95 154.59,32.65 273.14,7.79 134.15,-28.14 227.19,-126.74 210.26,-234.18 -15.56,-98.78 -78.42,-144.64 -183.02,-184.69 -4.99,-103.47 -48.37,-197.48 -117.37,-265.9 -25.59,-25.58 -54.51,-47.28 -86.21,-65.08 -19.34,-12.42 -50.08,-24.48 -52.86,-25.59 30.04,-7.23 62.86,-11.36 101.24,-12.24 160.75,-3.71 233.02,102.92 241.44,154.65l67.31 1.11c-5.57,-28.37 -59.54,-180.23 -318.2,-180.23 -47.84,0 -94.56,8.34 -136.28,22.25 -33.38,-8.9 -66.76,-13.91 -102.37,-13.91 -101.23,0 -403.3,0.56 -403.3,0.56l0 17.24c35.06,0 57.31,8.35 71.76,20.59 26.16,21.14 26.16,55.08 26.16,74.54 0,145.61 0,436.82 0,582.42 0,22.81 0,67.87 -45.63,86.78 -12.79,5.56 -30.05,9.46 -52.29,9.46l0 17.79c0,0 244.88,0 395.69,0 61.64,0 110.52,-13.35 110.52,-13.35zm-333.22 -11.13l0 0 0 -759.87 180.25 0c66.76,0 127.29,11.34 204.71,62.85 94.25,62.72 161.42,156.32 171.88,270.36 16.63,181.37 -83.58,312.32 -175.22,367.69 23.36,10.02 47.26,17.25 62.85,21.14 96.8,-61.19 164.89,-170.02 183.56,-279.25 84.01,39.5 122.93,81.22 129.07,142.41 4.99,52.85 -21.13,95.68 -63.42,126.83 -57.85,43.4 -148.04,64.57 -225.28,56.74 -162.45,-16.46 -249.97,-113.59 -270.37,-191.36l-70.07 0c12.84,56.49 58.52,124.57 147.4,169.66 0,0 -38.51,12.79 -97.35,12.79 -44.5,0 -178.02,0 -178.02,0z"/>
|
||||
<path class="fil0" d="M5014.02 409.98c23.36,7.23 44.49,13.91 64.51,20.58 1.12,-21.69 1.68,-51.73 -3.34,-80.66 -187.47,-59.52 -274.8,-87.89 -273.11,-189.13 0.55,-23.92 12.22,-47.28 34.46,-68.42 -13.33,-5 -27.81,-9.46 -41.14,-12.24 -42.46,35.57 -54.55,75.83 -54.51,107.37 0.11,118.51 90.66,167.99 273.13,222.51z"/>
|
||||
</g>
|
||||
<g id="_105553243768096">
|
||||
<g>
|
||||
<path class="fil0" d="M2918.1 1912.22c14.16,-25.88 21.22,-57.1 21.22,-93.67 0,-54.19 -15.76,-95.8 -47.32,-124.84 -31.53,-29.03 -76.11,-43.55 -133.69,-43.55l-123.76 0 0 341.9 138.32 0c32.67,0 61.52,-6.83 86.49,-20.52 25,-13.66 44.58,-33.43 58.74,-59.31zm-98.03 10.08l0 0c-14.97,9.61 -32.73,14.43 -53.28,14.43l-60.65 0 0 -231.25 50.71 0c35.44,0 62.69,9.67 81.77,29 19.1,19.33 28.65,47.35 28.65,84.08 0,24.26 -4.13,45.21 -12.39,62.85 -8.24,17.64 -19.85,31.27 -34.81,40.9z"/>
|
||||
<path class="fil0" d="M3164.42 1862.22l83.94 0 79.36 129.83 80.55 0 -92.44 -143.66c23.13,-5.49 41.5,-16.94 55.09,-34.33 13.58,-17.38 20.37,-38.06 20.37,-62 0,-32.84 -11.08,-58.03 -33.25,-75.6 -22.16,-17.54 -53.63,-26.31 -94.39,-26.31l-170.83 0 0 341.9 71.59 0 0 -129.83zm0 -156.51l0 0 91.71 0c21.05,0 36.81,4.12 47.32,12.38 10.52,8.25 15.77,20.55 15.77,36.89 0,16.33 -5.17,29.03 -15.53,38.09 -10.35,9.07 -25.54,13.59 -45.62,13.59l-93.65 0 0 -100.95z"/>
|
||||
<polygon class="fil0" points="3829.91,1936.72 3622.69,1936.72 3622.69,1846.69 3805.17,1846.69 3805.17,1791.37 3622.69,1791.37 3622.69,1705.48 3819.96,1705.48 3819.96,1650.16 3551.11,1650.16 3551.11,1992.05 3829.91,1992.05 "/>
|
||||
<path class="fil0" d="M4235.28 1827.54c-4.05,-3.88 -8.84,-7.53 -14.44,-10.93 -5.58,-3.39 -11.94,-6.63 -19.05,-9.71 -7.12,-2.91 -16.59,-5.93 -28.4,-9.09 -11.8,-3.16 -25.88,-6.51 -42.21,-10.06 -28.15,-5.99 -46.52,-10.93 -55.09,-14.81 -9.07,-4.03 -15.9,-8.88 -20.5,-14.56 -4.61,-5.66 -6.92,-12.93 -6.92,-21.83 0,-13.59 5.42,-23.79 16.26,-30.59 10.83,-6.79 27.08,-10.18 48.77,-10.18 20.38,0 36.04,3.71 46.96,11.15 10.91,7.45 18.15,18.53 21.72,33.25l69.39 -9.46c-6.15,-30.59 -19.85,-52.51 -41.13,-65.77 -21.26,-13.25 -53.09,-19.88 -95.48,-19.88 -44.32,0 -78.09,8.2 -101.29,24.61 -23.22,16.43 -34.83,40.09 -34.83,70.99 0,18.11 3.4,33.23 10.19,45.38 6.79,12.13 16.25,21.83 28.39,29.12 5.5,3.23 11.15,6.19 16.98,8.84 5.82,2.68 13.59,5.42 23.29,8.26 9.71,2.84 23.16,6.27 40.3,10.31 14.89,3.08 27.34,5.86 37.36,8.37 10.03,2.51 17.63,4.81 22.82,6.92 10.51,4.22 18.6,9.47 24.26,15.77 5.66,6.31 8.5,14.32 8.5,24.02 0,15.21 -6.23,26.61 -18.7,34.21 -12.45,7.59 -31.21,11.4 -56.3,11.4 -24.1,0 -42.94,-4.08 -56.53,-12.25 -13.59,-8.16 -22.72,-21.32 -27.41,-39.43l-69.16 11.39c14.23,62.61 64.64,93.91 151.17,93.91 48.05,0 84.5,-8.86 109.33,-26.57 24.81,-17.71 37.23,-43.32 37.23,-76.79 0,-14.24 -2.18,-26.83 -6.53,-37.75 -4.38,-10.91 -10.67,-20.35 -18.93,-28.26z"/>
|
||||
<path class="fil0" d="M4545.05 1996.9c48.05,0 84.5,-8.86 109.33,-26.57 24.81,-17.71 37.24,-43.32 37.24,-76.79 0,-14.24 -2.18,-26.83 -6.54,-37.75 -4.38,-10.91 -10.67,-20.35 -18.93,-28.26 -4.05,-3.88 -8.84,-7.53 -14.44,-10.93 -5.58,-3.39 -11.94,-6.63 -19.05,-9.71 -7.12,-2.91 -16.59,-5.93 -28.4,-9.09 -11.8,-3.16 -25.88,-6.51 -42.21,-10.06 -28.15,-5.99 -46.52,-10.93 -55.09,-14.81 -9.07,-4.03 -15.9,-8.88 -20.5,-14.56 -4.61,-5.66 -6.92,-12.93 -6.92,-21.83 0,-13.59 5.42,-23.79 16.26,-30.59 10.83,-6.79 27.08,-10.18 48.77,-10.18 20.38,0 36.04,3.71 46.96,11.15 10.91,7.45 18.15,18.53 21.72,33.25l69.39 -9.46c-6.15,-30.59 -19.85,-52.51 -41.13,-65.77 -21.26,-13.25 -53.09,-19.88 -95.48,-19.88 -44.32,0 -78.09,8.2 -101.29,24.61 -23.22,16.43 -34.83,40.09 -34.83,70.99 0,18.11 3.4,33.23 10.19,45.38 6.79,12.13 16.25,21.83 28.39,29.12 5.5,3.23 11.15,6.19 16.98,8.84 5.82,2.68 13.59,5.42 23.29,8.26 9.71,2.84 23.16,6.27 40.3,10.31 14.89,3.08 27.34,5.86 37.36,8.37 10.03,2.51 17.63,4.81 22.82,6.92 10.51,4.22 18.6,9.47 24.26,15.77 5.66,6.31 8.5,14.32 8.5,24.02 0,15.21 -6.23,26.61 -18.7,34.21 -12.45,7.59 -31.21,11.4 -56.3,11.4 -24.1,0 -42.94,-4.08 -56.53,-12.25 -13.59,-8.16 -22.72,-21.32 -27.41,-39.43l-69.16 11.39c14.23,62.61 64.64,93.91 151.17,93.91z"/>
|
||||
<polygon class="fil0" points="5112.53,1705.48 5112.53,1650.16 4843.67,1650.16 4843.67,1992.05 5122.48,1992.05 5122.48,1936.72 4915.25,1936.72 4915.25,1846.69 5097.74,1846.69 5097.74,1791.37 4915.25,1791.37 4915.25,1705.48 "/>
|
||||
<path class="fil0" d="M5274.51 1992.05l138.33 0c32.66,0 61.51,-6.83 86.49,-20.52 24.99,-13.66 44.58,-33.43 58.74,-59.31 14.15,-25.88 21.22,-57.1 21.22,-93.67 0,-54.19 -15.76,-95.8 -47.32,-124.84 -31.54,-29.03 -76.11,-43.55 -133.69,-43.55l-123.77 0 0 341.9zm71.59 -286.58l0 0 50.71 0c35.44,0 62.68,9.67 81.77,29 19.1,19.33 28.64,47.35 28.64,84.08 0,24.26 -4.12,45.21 -12.38,62.85 -8.25,17.64 -19.85,31.27 -34.82,40.9 -14.96,9.61 -32.72,14.43 -53.27,14.43l-60.65 0 0 -231.25z"/>
|
||||
<polygon class="fil0" points="6041.85,1866.6 6216.8,1866.6 6216.8,1811.27 6041.85,1811.27 6041.85,1705.48 6222.38,1705.48 6222.38,1650.16 5970.26,1650.16 5970.26,1992.05 6041.85,1992.05 "/>
|
||||
<path class="fil0" d="M6440.16 1975.42c25.73,14.32 56.46,21.48 92.21,21.48 34.95,0 65.41,-7.36 91.37,-22.09 25.97,-14.71 46.09,-35.38 60.42,-61.99 14.32,-26.61 21.48,-57.71 21.48,-93.31 0,-36.23 -6.89,-67.38 -20.64,-93.42 -13.73,-26.04 -33.47,-46.06 -59.21,-60.05 -25.72,-13.98 -56.7,-20.98 -92.92,-20.98 -36.08,0 -67.03,6.9 -92.81,20.73 -25.8,13.84 -45.54,33.77 -59.21,59.83 -13.68,26.03 -20.51,57.33 -20.51,93.89 0,36.57 6.86,68.1 20.61,94.64 13.76,26.53 33.5,46.96 59.21,61.26zm18.45 -242.89l0 0c17.47,-20.79 42.23,-31.17 74.26,-31.17 31.54,0 56.04,10.48 73.51,31.41 17.47,20.95 26.21,49.86 26.21,86.75 0,38.67 -8.68,68.5 -26.08,89.55 -17.39,21.02 -42.1,31.53 -74.13,31.53 -20.55,0 -38.28,-4.89 -53.25,-14.69 -14.97,-9.78 -26.5,-23.73 -34.58,-41.85 -8.09,-18.11 -12.14,-39.63 -12.14,-64.55 0,-37.2 8.73,-66.2 26.21,-86.99z"/>
|
||||
<path class="fil0" d="M6930.73 1862.22l83.95 0 79.35 129.83 80.56 0 -92.45 -143.66c23.13,-5.49 41.5,-16.94 55.09,-34.33 13.59,-17.38 20.38,-38.06 20.38,-62 0,-32.84 -11.09,-58.03 -33.26,-75.6 -22.15,-17.54 -53.63,-26.31 -94.38,-26.31l-170.83 0 0 341.9 71.58 0 0 -129.83zm0 -156.51l0 0 91.72 0c21.04,0 36.8,4.12 47.32,12.38 10.51,8.25 15.76,20.55 15.76,36.89 0,16.33 -5.17,29.03 -15.53,38.09 -10.35,9.07 -25.54,13.59 -45.62,13.59l-93.65 0 0 -100.95z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="fil0" d="M626.71 3125.34c-312.09,-99.81 -457.62,-145.91 -454.68,-313.28 1.92,-109.35 154.05,-225.3 405.27,-225.24 287.5,0.07 384.64,181.28 397.49,255.31l111.45 1.45c-9.61,-46.43 -97.73,-297.37 -524.05,-297.37 -259.08,0 -494.54,135.56 -490.96,308.98 4,195.85 148.98,276.42 449.48,366.6 355.15,106.58 500.3,187.04 515.41,344.66 19.94,208.05 -256.07,322.96 -474.74,301.33 -358.56,-35.46 -443.99,-293.28 -445.51,-314.63l-115.87 0c38.99,194.95 288.15,355.74 561.59,355.74 379.27,0 604.13,-178.39 572.63,-410.05 -30.04,-221.08 -214.12,-279.64 -507.52,-373.49z"/>
|
||||
<path class="fil0" d="M9782.93 3498.83c-30.06,-221.08 -214.12,-279.64 -507.52,-373.49 -312.09,-99.81 -457.62,-145.91 -454.68,-313.28 1.9,-109.35 154.05,-225.3 405.27,-225.24 287.5,0.07 384.61,181.28 397.49,255.31l111.45 1.45c-9.61,-46.43 -97.73,-297.37 -524.05,-297.37 -259.08,0 -494.54,135.56 -490.96,308.98 4,195.85 148.98,276.42 449.49,366.6 355.12,106.58 500.29,187.04 515.4,344.66 19.94,208.05 -256.07,322.96 -474.74,301.33 -358.56,-35.46 -443.99,-293.28 -445.51,-314.63l-115.89 0c39.01,194.95 288.16,355.74 561.61,355.74 379.27,0 604.13,-178.39 572.63,-410.05z"/>
|
||||
<path class="fil0" d="M5596.47 3397.18c-63.07,271.65 -276.01,471.5 -528.74,471.5 -303.18,0 -548.64,-287.17 -548.64,-641.28 0,-354.12 245.46,-641.29 548.64,-641.29 253.2,0 465.67,199.85 528.74,471.99l131.94 0c-75.18,-294.45 -342.95,-512.25 -660.67,-512.25 -376.44,0 -682.04,305.12 -682.04,681.55 0,376.91 305.6,682.03 682.04,682.03 317.72,0 585,-217.32 660.67,-512.25l-131.94 0z"/>
|
||||
<path class="fil0" d="M7151.54 3124.56l54.34 0 78.12 0c-49.51,-327.43 -332.32,-578.71 -673.82,-578.71 -376.89,0 -682.01,305.12 -682.01,681.55 0,376.91 305.12,682.03 682.01,682.03 258.55,0 483.2,-143.58 598.64,-356.05l-126.15 0c-95.55,188.7 -271.63,315.3 -472.49,315.3 -303.18,0 -549.08,-287.17 -549.08,-641.28 0,-34.93 2.42,-69.37 7.25,-102.84l1083.2 0zm-541.36 -538.45l0 0c261.01,0 479.78,213.44 535.07,499.16l-1070.56 0c55.29,-285.72 274.05,-499.16 535.49,-499.16z"/>
|
||||
<path class="fil0" d="M7996.21 3125.34c-312.06,-99.81 -457.62,-145.91 -454.68,-313.28 1.92,-109.35 154.05,-225.3 405.29,-225.24 287.5,0.07 384.62,181.28 397.47,255.31l111.45 1.45c-9.61,-46.43 -97.7,-297.37 -524.03,-297.37 -259.07,0 -494.53,135.56 -490.98,308.98 4.02,195.85 148.98,276.42 449.48,366.6 355.15,106.58 500.3,187.04 515.41,344.66 19.94,208.05 -256.04,322.96 -474.74,301.33 -358.54,-35.46 -444,-293.28 -445.51,-314.63l-115.87 0c38.99,194.95 288.16,355.74 561.59,355.74 379.26,0 604.13,-178.39 572.65,-410.05 -30.06,-221.08 -214.14,-279.64 -507.54,-373.49z"/>
|
||||
<path class="fil0" d="M3525.75 2586.11c252.73,0 465.69,199.85 528.75,471.99l131.46 0c-75.18,-294.45 -342.47,-512.25 -660.2,-512.25 -376.91,0 -682.03,305.12 -682.03,681.55 0,376.91 305.12,682.03 682.03,682.03 317.73,0 584.53,-217.32 660.2,-512.25l-131.46 0c-63.06,271.65 -276.49,471.5 -528.75,471.5 -303.18,0 -549.11,-287.17 -549.11,-641.28 0,-354.12 245.93,-641.29 549.11,-641.29z"/>
|
||||
<path class="fil0" d="M2451.5 2559.5l-0.19 0 0 848.33c0,254.58 -206.37,460.96 -460.95,460.96 -254.61,0 -460.97,-206.38 -460.97,-460.96l0 -848.33 -123.69 0 0 851.72c0,335.84 312.91,498.18 588.05,498.18 205.31,0 381.48,-124.25 457.75,-301.59l0 260.98 123.69 0 0 -1309.62 -123.69 0 0 0.33z"/>
|
||||
</g>
|
||||
</g>
|
||||
<polygon class="fil0" points="2756.34,4729.87 2801.29,4729.87 2801.29,4865.66 2972.41,4865.66 2972.41,4729.87 3017.36,4729.87 3017.36,5058.8 2972.41,5058.8 2972.41,4905.12 2801.29,4905.12 2801.29,5058.8 2756.34,5058.8 "/>
|
||||
<path id="_1" class="fil0" d="M3317.68 4720.69c57.81,0 100.93,18.82 128.91,55.97 22.02,29.35 32.57,66.52 32.57,111.94 0,49.08 -12.39,89.91 -37.16,122.49 -29.35,38.07 -71.1,57.34 -125.24,57.34 -50.92,0 -90.37,-16.51 -119.27,-50.01 -26.15,-32.57 -39,-73.39 -39,-122.49 0,-44.49 11.01,-82.57 33.03,-114.23 28.44,-40.83 70.65,-61.02 126.15,-61.02zm4.59 307.83c39,0 67.44,-14.22 84.87,-42.2 17.89,-27.99 26.6,-60.55 26.6,-97.26 0,-38.53 -10.09,-69.73 -30.28,-93.12 -20.64,-23.86 -48.17,-35.33 -83.04,-35.33 -34.41,0 -61.93,11.47 -83.49,34.87 -21.56,23.4 -32.12,57.8 -32.12,103.22 0,36.71 9.18,67.44 27.52,92.21 18.35,25.23 48.17,37.62 89.92,37.62z"/>
|
||||
<path id="_2" class="fil0" d="M3620.6 4729.87l63.77 0 94.5 278 94.05 -278 62.85 0 0 328.92 -42.21 0 0 -194.05c0,-6.88 0,-17.89 0.46,-33.49 0.46,-15.6 0.46,-32.12 0.46,-50.01l-94.05 277.55 -44.04 0 -94.51 -277.55 0 10.1c0,8.26 0,20.18 0.46,36.71 0.46,16.51 0.91,28.9 0.91,36.7l0 194.05 -42.66 0 0 -328.92z"/>
|
||||
<polygon id="_3" class="fil0" points="4097.01,4729.87 4336.94,4729.87 4336.94,4770.24 4140.59,4770.24 4140.59,4869.79 4322.26,4869.79 4322.26,4907.87 4140.59,4907.87 4140.59,5019.8 4340.61,5019.8 4340.61,5058.8 4097.01,5058.8 "/>
|
||||
<path id="_4" class="fil0" d="M5345.98 4952.83c0.92,18.35 5.5,33.49 12.85,45.42 15.14,21.56 40.83,32.57 78.45,32.57 16.51,0 32.12,-2.29 45.42,-7.33 26.61,-9.18 39.91,-25.7 39.91,-49.54 0,-17.89 -5.5,-30.74 -16.51,-38.53 -11.47,-7.34 -29.36,-13.76 -53.21,-19.27l-44.96 -10.1c-28.9,-6.42 -49.54,-13.76 -61.47,-21.56 -21.11,-13.76 -31.65,-34.41 -31.65,-61.94 0,-29.35 10.55,-53.67 30.74,-72.48 20.64,-19.27 49.54,-28.44 87.16,-28.44 34.41,0 63.77,8.26 87.63,24.77 24.31,16.51 36.24,43.12 36.24,79.82l-41.75 0c-2.3,-17.44 -6.88,-31.2 -14.22,-40.37 -13.76,-17.43 -36.7,-25.69 -69.27,-25.69 -26.6,0 -45.42,5.5 -56.88,16.51 -11.47,11.01 -16.98,23.85 -16.98,38.53 0,16.05 6.42,27.99 19.73,35.32 9.17,4.59 28.9,10.56 60.09,17.89l45.88 10.56c22.47,5.04 39.45,11.92 51.84,20.64 20.64,15.59 31.2,38.07 31.2,66.97 0,36.7 -13.31,62.85 -39.91,78.45 -26.16,15.6 -56.89,23.4 -92.22,23.4 -40.83,0 -72.94,-10.55 -95.88,-31.19 -23.4,-21.11 -34.87,-49.09 -34.41,-84.41l42.2 0z"/>
|
||||
<polygon id="_5" class="fil0" points="5711.83,4729.87 5756.32,4729.87 5756.32,5019.8 5923.31,5019.8 5923.31,5058.8 5711.83,5058.8 "/>
|
||||
<polygon id="_6" class="fil0" points="6060.32,4729.87 6300.25,4729.87 6300.25,4770.24 6103.9,4770.24 6103.9,4869.79 6285.56,4869.79 6285.56,4907.87 6103.9,4907.87 6103.9,5019.8 6303.91,5019.8 6303.91,5058.8 6060.32,5058.8 "/>
|
||||
<polygon id="_7" class="fil0" points="6455.52,4729.87 6695.45,4729.87 6695.45,4770.24 6499.1,4770.24 6499.1,4869.79 6680.76,4869.79 6680.76,4907.87 6499.1,4907.87 6499.1,5019.8 6699.12,5019.8 6699.12,5058.8 6455.52,5058.8 "/>
|
||||
<path id="_8" class="fil0" d="M6850.72 4729.87l148.17 0c29.36,0 52.76,8.26 71.11,24.77 17.89,16.51 26.6,39.45 26.6,69.26 0,25.7 -7.8,48.18 -23.85,67.45 -16.06,18.81 -40.84,28.44 -73.86,28.44l-103.22 0 0 139 -44.95 0 0 -328.92zm200.93 94.5c0,-24.31 -9.18,-40.83 -27.07,-49.54 -9.63,-4.59 -23.4,-6.88 -40.36,-6.88l-88.54 0 0 114.23 88.54 0c20.18,0 36.23,-4.58 48.62,-12.84 12.4,-8.72 18.82,-23.4 18.82,-44.96z"/>
|
||||
<path id="_9" class="fil0" d="M4801.09 4857.86c14.21,-10.1 23.85,-18.35 29.35,-24.77 8.72,-10.1 12.85,-21.11 12.85,-33.49 0,-10.1 -3.21,-18.35 -9.63,-25.23 -6.42,-6.88 -14.68,-10.55 -25.69,-10.55 -16.51,0 -27.99,5.5 -34.41,16.51 -3.68,5.5 -5.05,11.92 -5.05,18.81 0,8.72 2.3,17.44 7.34,26.15 5.04,8.27 13.3,19.28 25.23,32.58zm-10.56 172.94c16.51,0 30.74,-3.67 42.66,-11.01 11.93,-7.8 21.57,-16.51 27.99,-25.69l-74.78 -90.83c-21.1,14.22 -34.41,24.77 -40.83,32.12 -10.1,11.46 -15.14,25.22 -15.14,41.29 0,17.43 6.42,30.73 19.27,40.36 12.85,9.18 26.61,13.76 40.83,13.76zm-26.15 -155.05c-14.21,-16.05 -23.4,-29.36 -27.98,-40.37 -4.59,-11.01 -7.34,-21.56 -7.34,-31.65 0,-21.11 7.34,-38.53 21.56,-52.76 14.69,-13.76 33.49,-20.64 57.8,-20.64 22.94,0 40.37,6.43 53.22,19.27 12.84,12.85 19.27,28.45 19.27,46.79 0,21.11 -6.43,39.46 -19.73,55.06 -7.8,9.63 -20.64,20.18 -39,32.11l60.1 71.56c4.13,-11.92 6.88,-20.64 8.26,-26.6 1.83,-5.97 3.2,-14.22 5.04,-24.77l38.08 0c-2.3,21.1 -7.34,41.29 -15.14,60.55 -7.81,19.27 -11.47,27.06 -11.47,23.4l58.72 71.1 -52.3 0 -30.74 -37.62c-12.39,13.31 -23.4,22.94 -33.48,28.9 -17.89,11.01 -38.53,16.51 -61.48,16.51 -34.41,0 -59.18,-9.17 -74.77,-27.98 -15.6,-18.35 -23.4,-39 -23.4,-62.39 0,-24.77 7.8,-45.42 22.93,-62.39 9.17,-10.09 26.61,-22.93 51.84,-38.07z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
BIN
Logo DRESSED FOR SUCCESS/Резервная_копия_Dressed for Success.cdr
Normal file
BIN
backend/.DS_Store
vendored
13
backend/.env
Normal file
@ -0,0 +1,13 @@
|
||||
# Настройки базы данных
|
||||
DATABASE_URL=postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
|
||||
|
||||
# Настройки безопасности
|
||||
SECRET_KEY=supersecretkey
|
||||
DEBUG=1
|
||||
|
||||
# Настройки загрузки файлов
|
||||
UPLOAD_DIRECTORY=uploads
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL=http://localhost:7700
|
||||
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
@ -7,4 +7,8 @@ SECRET_KEY=supersecretkey
|
||||
# UPLOAD_DIRECTORY=/app/uploads
|
||||
|
||||
# Настройки CORS
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL=http://meilisearch:7700
|
||||
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
|
||||
26
backend/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# Используем официальный образ с Uvicorn+Gunicorn, оптимизированный для FastAPI
|
||||
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY ./app ./app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Точка входа: стандартный CMD из базового образа запустит Gunicorn+Uvicorn
|
||||
|
||||
|
||||
|
||||
# FROM python:3.10-slim
|
||||
|
||||
# WORKDIR /app
|
||||
|
||||
# COPY requirements.txt .
|
||||
|
||||
# RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# # Для разработки код монтируется через volumes, а в продакшн-билде можно добавить COPY . /app
|
||||
# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # hot-reload
|
||||
@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db
|
||||
sqlalchemy.url = postgresql://gen_user:F%%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
BIN
backend/alembic/.DS_Store
vendored
42
backend/alembic/versions/7192b0707277_add_new_order.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""add_new_order
|
||||
|
||||
Revision ID: 7192b0707277
|
||||
Revises: 9773b0186faa
|
||||
Create Date: 2025-04-27 01:56:31.170476
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7192b0707277'
|
||||
down_revision: Union[str, None] = '9773b0186faa'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('orders', sa.Column('user_info_json', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('delivery_method', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('city', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('delivery_address', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('cdek_info', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('courier_info', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('items_json', sa.JSON(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('orders', 'items_json')
|
||||
op.drop_column('orders', 'courier_info')
|
||||
op.drop_column('orders', 'cdek_info')
|
||||
op.drop_column('orders', 'delivery_address')
|
||||
op.drop_column('orders', 'city')
|
||||
op.drop_column('orders', 'delivery_method')
|
||||
op.drop_column('orders', 'user_info_json')
|
||||
# ### end Alembic commands ###
|
||||
30
backend/alembic/versions/f89a59b0e814_add_new_order_.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""add_new_order_
|
||||
|
||||
Revision ID: f89a59b0e814
|
||||
Revises: 7192b0707277
|
||||
Create Date: 2025-04-27 02:49:23.818786
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f89a59b0e814'
|
||||
down_revision: Union[str, None] = '7192b0707277'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('orders', sa.Column('payment_method', sa.Enum('CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'CASH_ON_DELIVERY', 'SBP', 'CARD', name='paymentmethod'), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('orders', 'payment_method')
|
||||
# ### end Alembic commands ###
|
||||
BIN
backend/app/.DS_Store
vendored
@ -7,4 +7,7 @@ MINIO_ENDPOINT_URL = http://45.129.128.113:9000
|
||||
MINIO_ACCESS_KEY = ZIK_DressedForSuccess
|
||||
MINIO_SECRET_KEY = ZIK_DressedForSuccess_/////ZIK_DressedForSuccess_!
|
||||
MINIO_BUCKET_NAME = dressedforsuccess
|
||||
MINIO_USE_SSL = false
|
||||
MINIO_USE_SSL = false
|
||||
|
||||
MEILISEARCH_KEY = dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
MEILISEARCH_URL = http://localhost:7700
|
||||
BIN
backend/app/__pycache__/cache_with_logging.cpython-310.pyc
Normal file
@ -8,7 +8,7 @@ load_dotenv()
|
||||
|
||||
# Базовые настройки
|
||||
API_PREFIX = "/api"
|
||||
DEBUG = True
|
||||
DEBUG = False
|
||||
|
||||
# Настройки безопасности
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") # Для JWT
|
||||
@ -53,15 +53,16 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "Интернет-магазин API"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI"
|
||||
|
||||
DEBUG: bool = False
|
||||
|
||||
# Настройки базы данных
|
||||
DATABASE_URL: str = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db"
|
||||
|
||||
|
||||
# Настройки безопасности
|
||||
SECRET_KEY: str = SECRET_KEY
|
||||
ALGORITHM: str = ALGORITHM
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
|
||||
# Настройки CORS
|
||||
CORS_ORIGINS: list = [
|
||||
"http://localhost",
|
||||
@ -69,39 +70,44 @@ class Settings(BaseSettings):
|
||||
"http://localhost:8000",
|
||||
"http://localhost:8080",
|
||||
]
|
||||
|
||||
# Старые настройки для загрузки файлов (удалить или закомментировать)
|
||||
# UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY
|
||||
|
||||
# Настройки для загрузки файлов
|
||||
UPLOAD_DIRECTORY: str = "uploads"
|
||||
MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE
|
||||
ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS)
|
||||
|
||||
|
||||
# Настройки MinIO/S3
|
||||
MINIO_ENDPOINT_URL: str = MINIO_ENDPOINT_URL
|
||||
MINIO_ACCESS_KEY: str = MINIO_ACCESS_KEY
|
||||
MINIO_SECRET_KEY: str = MINIO_SECRET_KEY
|
||||
MINIO_BUCKET_NAME: str = MINIO_BUCKET_NAME
|
||||
MINIO_USE_SSL: bool = MINIO_USE_SSL
|
||||
|
||||
|
||||
# Настройки для платежных систем (пример)
|
||||
PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "")
|
||||
PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "")
|
||||
|
||||
|
||||
# Настройки для CDEK API
|
||||
CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login")
|
||||
CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass")
|
||||
CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2")
|
||||
|
||||
|
||||
# Настройки для отправки email (пример)
|
||||
SMTP_SERVER: str = MAIL_SERVER
|
||||
SMTP_PORT: int = MAIL_PORT
|
||||
SMTP_USERNAME: str = MAIL_USERNAME
|
||||
SMTP_PASSWORD: str = MAIL_PASSWORD
|
||||
EMAIL_FROM: str = MAIL_FROM
|
||||
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://0.0.0.0:7700")
|
||||
MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey")
|
||||
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
# Создаем экземпляр настроек
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
@ -1,23 +1,57 @@
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import router
|
||||
from app.core import Base, engine
|
||||
from app.core import Base, engine, SessionLocal
|
||||
from app.services import meilisearch_service
|
||||
from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Создаем таблицы в базе данных
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Инициализируем Meilisearch
|
||||
logging.info("Инициализация Meilisearch...")
|
||||
try:
|
||||
# Инициализируем индексы
|
||||
meilisearch_service.initialize_indexes()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Синхронизируем данные с Meilisearch
|
||||
sync_categories(db)
|
||||
sync_collections(db)
|
||||
sync_sizes(db)
|
||||
sync_products(db)
|
||||
logging.info("Синхронизация с Meilisearch завершена успешно")
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при инициализации Meilisearch: {str(e)}")
|
||||
|
||||
yield
|
||||
|
||||
# Создаем экземпляр приложения FastAPI
|
||||
app = FastAPI(
|
||||
title="Dressed for Success API",
|
||||
description="API для интернет-магазина одежды",
|
||||
version="1.0.0"
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Настраиваем CORS
|
||||
@ -40,7 +74,10 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
|
||||
# Подключаем роутеры
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
|
||||
|
||||
|
||||
# Корневой маршрут
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
|
||||
return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
@ -20,6 +20,8 @@ class PaymentMethod(str, enum.Enum):
|
||||
PAYPAL = "paypal"
|
||||
BANK_TRANSFER = "bank_transfer"
|
||||
CASH_ON_DELIVERY = "cash_on_delivery"
|
||||
SBP = "sbp"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
class CartItem(Base):
|
||||
@ -44,9 +46,28 @@ class Order(Base):
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
|
||||
total_amount = Column(Float, nullable=False)
|
||||
|
||||
# JSON-поле с информацией о пользователе (для резервного копирования)
|
||||
user_info_json = Column(JSON, nullable=True)
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method = Column(String, nullable=True) # cdek, courier
|
||||
city = Column(String, nullable=True)
|
||||
delivery_address = Column(String, nullable=True) # Форматированный адрес
|
||||
cdek_info = Column(JSON, nullable=True) # Информация о доставке CDEK
|
||||
courier_info = Column(JSON, nullable=True) # Информация о курьерской доставке
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
shipping_address_id = Column(Integer, ForeignKey("user_addresses.id"), nullable=True)
|
||||
|
||||
# Информация об оплате
|
||||
payment_method = Column(Enum(PaymentMethod), nullable=True)
|
||||
payment_details = Column(Text, nullable=True)
|
||||
|
||||
# JSON-поле со списком заказанных товаров (для резервного копирования)
|
||||
items_json = Column(JSON, nullable=True)
|
||||
|
||||
# Дополнительная информация
|
||||
tracking_number = Column(String, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@ -70,4 +91,4 @@ class OrderItem(Base):
|
||||
|
||||
# Отношения
|
||||
order = relationship("Order", back_populates="items")
|
||||
variant = relationship("ProductVariant")
|
||||
variant = relationship("ProductVariant")
|
||||
@ -28,7 +28,7 @@ def generate_slug(name: str) -> str:
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
# Удаляем дефисы в начале и конце
|
||||
slug = slug.strip('-')
|
||||
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
@ -42,16 +42,16 @@ def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]:
|
||||
|
||||
|
||||
def get_collections(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: Optional[bool] = True
|
||||
) -> List[Collection]:
|
||||
query = db.query(Collection)
|
||||
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Collection.is_active == is_active)
|
||||
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@ -59,14 +59,14 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
|
||||
# Если slug не предоставлен, генерируем его из имени
|
||||
if not collection.slug:
|
||||
collection.slug = generate_slug(collection.name)
|
||||
|
||||
|
||||
# Проверяем, что коллекция с таким slug не существует
|
||||
if get_collection_by_slug(db, collection.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Коллекция с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новую коллекцию
|
||||
db_collection = Collection(
|
||||
name=collection.name,
|
||||
@ -74,7 +74,7 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
|
||||
description=collection.description,
|
||||
is_active=collection.is_active
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_collection)
|
||||
db.commit()
|
||||
@ -95,10 +95,10 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Коллекция не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = collection.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if "slug" in update_data and update_data["slug"] != db_collection.slug:
|
||||
if get_collection_by_slug(db, update_data["slug"]):
|
||||
@ -106,7 +106,7 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Коллекция с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Если имя изменяется и slug не предоставлен, генерируем новый slug
|
||||
if "name" in update_data and "slug" not in update_data:
|
||||
update_data["slug"] = generate_slug(update_data["name"])
|
||||
@ -116,11 +116,11 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Коллекция с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_collection, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_collection)
|
||||
@ -140,14 +140,14 @@ def delete_collection(db: Session, collection_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Коллекция не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, есть ли у коллекции продукты
|
||||
if db.query(Product).filter(Product.collection_id == collection_id).count() > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя удалить коллекцию, у которой есть продукты"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_collection)
|
||||
db.commit()
|
||||
@ -170,18 +170,18 @@ def get_category_by_slug(db: Session, slug: str) -> Optional[Category]:
|
||||
|
||||
|
||||
def get_categories(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
parent_id: Optional[int] = None
|
||||
) -> List[Category]:
|
||||
query = db.query(Category)
|
||||
|
||||
|
||||
if parent_id is not None:
|
||||
query = query.filter(Category.parent_id == parent_id)
|
||||
else:
|
||||
query = query.filter(Category.parent_id == None)
|
||||
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@ -189,21 +189,21 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
|
||||
# Если slug не предоставлен, генерируем его из имени
|
||||
if not category.slug:
|
||||
category.slug = generate_slug(category.name)
|
||||
|
||||
|
||||
# Проверяем, что категория с таким slug не существует
|
||||
if get_category_by_slug(db, category.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Категория с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что родительская категория существует, если указана
|
||||
if category.parent_id and not get_category(db, category.parent_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Родительская категория не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новую категорию
|
||||
db_category = Category(
|
||||
name=category.name,
|
||||
@ -212,7 +212,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
|
||||
parent_id=category.parent_id,
|
||||
is_active=category.is_active
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_category)
|
||||
db.commit()
|
||||
@ -233,10 +233,10 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Категория не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = category.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if "slug" in update_data and update_data["slug"] != db_category.slug:
|
||||
if get_category_by_slug(db, update_data["slug"]):
|
||||
@ -244,7 +244,7 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Категория с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Если имя изменяется и slug не предоставлен, генерируем новый slug
|
||||
if "name" in update_data and "slug" not in update_data:
|
||||
update_data["slug"] = generate_slug(update_data["name"])
|
||||
@ -254,25 +254,25 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Категория с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что родительская категория существует, если указана
|
||||
if "parent_id" in update_data and update_data["parent_id"] and not get_category(db, update_data["parent_id"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Родительская категория не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что категория не становится своим собственным родителем
|
||||
if "parent_id" in update_data and update_data["parent_id"] == category_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Категория не может быть своим собственным родителем"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_category, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_category)
|
||||
@ -292,21 +292,21 @@ def delete_category(db: Session, category_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Категория не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, есть ли у категории подкатегории
|
||||
if db.query(Category).filter(Category.parent_id == category_id).count() > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя удалить категорию, у которой есть подкатегории"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, есть ли у категории продукты
|
||||
if db.query(Product).filter(Product.category_id == category_id).count() > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя удалить категорию, у которой есть продукты"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_category)
|
||||
db.commit()
|
||||
@ -329,50 +329,52 @@ def get_product_by_slug(db: Session, slug: str) -> Optional[Product]:
|
||||
|
||||
|
||||
def get_products(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
category_id: Optional[int] = None,
|
||||
collection_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
is_active: Optional[bool] = True,
|
||||
include_variants: Optional[bool] = False
|
||||
is_active: Optional[bool] = True
|
||||
) -> List[Product]:
|
||||
query = db.query(Product)
|
||||
|
||||
|
||||
# Применяем фильтры
|
||||
if category_id:
|
||||
query = query.filter(Product.category_id == category_id)
|
||||
|
||||
|
||||
if collection_id:
|
||||
query = query.filter(Product.collection_id == collection_id)
|
||||
|
||||
|
||||
if search:
|
||||
query = query.filter(Product.name.ilike(f"%{search}%"))
|
||||
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Product.is_active == is_active)
|
||||
|
||||
|
||||
# Фильтрация по цене теперь должна быть через варианты продукта
|
||||
if min_price is not None or max_price is not None:
|
||||
query = query.join(ProductVariant)
|
||||
|
||||
|
||||
if min_price is not None:
|
||||
query = query.filter(ProductVariant.price >= min_price)
|
||||
|
||||
|
||||
if max_price is not None:
|
||||
query = query.filter(ProductVariant.price <= max_price)
|
||||
|
||||
|
||||
products = query.offset(skip).limit(limit).all()
|
||||
|
||||
# Если нужно включить варианты, загружаем их для каждого продукта
|
||||
if include_variants:
|
||||
for product in products:
|
||||
product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
|
||||
product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
|
||||
|
||||
|
||||
# Всегда загружаем варианты и изображения для каждого продукта
|
||||
for product in products:
|
||||
# Загружаем варианты с размерами
|
||||
variants = db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product.id).all()
|
||||
product.variants = variants
|
||||
|
||||
# Загружаем изображения
|
||||
product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
|
||||
|
||||
return products
|
||||
|
||||
|
||||
@ -380,28 +382,28 @@ def create_product(db: Session, product: ProductCreate) -> Product:
|
||||
# Если slug не предоставлен, генерируем его из имени
|
||||
if not product.slug:
|
||||
product.slug = generate_slug(product.name)
|
||||
|
||||
|
||||
# Проверяем, что slug уникален
|
||||
if db.query(Product).filter(Product.slug == product.slug).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что категория существует
|
||||
if not get_category(db, product.category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что коллекция существует, если она указана
|
||||
if product.collection_id and not get_collection(db, product.collection_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
db_product = Product(
|
||||
name=product.name,
|
||||
slug=product.slug,
|
||||
@ -413,7 +415,7 @@ def create_product(db: Session, product: ProductCreate) -> Product:
|
||||
category_id=product.category_id,
|
||||
collection_id=product.collection_id
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_product)
|
||||
db.commit()
|
||||
@ -434,7 +436,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if product.slug is not None and product.slug != db_product.slug:
|
||||
if db.query(Product).filter(Product.slug == product.slug).first():
|
||||
@ -442,7 +444,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Если имя изменяется и slug не предоставлен, обновляем slug
|
||||
if product.name is not None and product.name != db_product.name and product.slug is None:
|
||||
product.slug = generate_slug(product.name)
|
||||
@ -452,7 +454,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что категория существует, если она изменяется
|
||||
if product.category_id is not None and product.category_id != db_product.category_id:
|
||||
if not get_category(db, product.category_id):
|
||||
@ -460,7 +462,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что коллекция существует, если она изменяется
|
||||
if product.collection_id is not None and product.collection_id != db_product.collection_id:
|
||||
if product.collection_id and not get_collection(db, product.collection_id):
|
||||
@ -468,7 +470,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем поля
|
||||
if product.name is not None:
|
||||
db_product.name = product.name
|
||||
@ -488,7 +490,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
db_product.category_id = product.category_id
|
||||
if product.collection_id is not None:
|
||||
db_product.collection_id = product.collection_id
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_product)
|
||||
@ -508,16 +510,36 @@ def delete_product(db: Session, product_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
)
|
||||
|
||||
|
||||
# Получаем ID всех вариантов продукта
|
||||
variant_ids = [variant.id for variant in db_product.variants]
|
||||
|
||||
# Проверяем, используются ли варианты в корзинах
|
||||
from app.models.order_models import CartItem
|
||||
cart_items_count = db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).count()
|
||||
if cart_items_count > 0:
|
||||
# Удаляем все элементы корзины, связанные с вариантами продукта
|
||||
db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Проверяем, используются ли варианты в заказах
|
||||
from app.models.order_models import OrderItem
|
||||
order_items_count = db.query(OrderItem).filter(OrderItem.variant_id.in_(variant_ids)).count()
|
||||
if order_items_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно удалить продукт, так как его варианты используются в {order_items_count} заказах"
|
||||
)
|
||||
|
||||
try:
|
||||
# Удаляем продукт (варианты и изображения удалятся автоматически благодаря cascade)
|
||||
db.delete(db_product)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при удалении продукта"
|
||||
detail=f"Ошибка при удалении продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -527,7 +549,8 @@ def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant
|
||||
|
||||
|
||||
def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]:
|
||||
return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all()
|
||||
# Загружаем варианты с размерами
|
||||
return db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product_id).all()
|
||||
|
||||
|
||||
def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant:
|
||||
@ -538,7 +561,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что размер существует
|
||||
db_size = get_size(db, variant.size_id)
|
||||
if not db_size:
|
||||
@ -546,7 +569,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что вариант с таким SKU не существует
|
||||
existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first()
|
||||
if existing_variant:
|
||||
@ -554,7 +577,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что вариант с таким размером для этого продукта не существует
|
||||
existing_size_variant = db.query(ProductVariant).filter(
|
||||
ProductVariant.product_id == variant.product_id,
|
||||
@ -565,7 +588,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
|
||||
)
|
||||
|
||||
|
||||
db_variant = ProductVariant(
|
||||
product_id=variant.product_id,
|
||||
size_id=variant.size_id,
|
||||
@ -573,7 +596,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
stock=variant.stock,
|
||||
is_active=variant.is_active
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_variant)
|
||||
db.commit()
|
||||
@ -594,7 +617,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что продукт существует, если ID продукта изменяется
|
||||
if variant.product_id is not None and variant.product_id != db_variant.product_id:
|
||||
db_product = get_product(db, variant.product_id)
|
||||
@ -603,7 +626,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что размер существует, если ID размера изменяется
|
||||
if variant.size_id is not None and variant.size_id != db_variant.size_id:
|
||||
db_size = get_size(db, variant.size_id)
|
||||
@ -612,7 +635,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что вариант с таким размером для этого продукта не существует
|
||||
product_id = variant.product_id if variant.product_id is not None else db_variant.product_id
|
||||
existing_size_variant = db.query(ProductVariant).filter(
|
||||
@ -625,7 +648,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что SKU уникален, если он изменяется
|
||||
if variant.sku is not None and variant.sku != db_variant.sku:
|
||||
existing_variant = db.query(ProductVariant).filter(
|
||||
@ -637,7 +660,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем поля
|
||||
if variant.product_id is not None:
|
||||
db_variant.product_id = variant.product_id
|
||||
@ -649,7 +672,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
db_variant.stock = variant.stock
|
||||
if variant.is_active is not None:
|
||||
db_variant.is_active = variant.is_active
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_variant)
|
||||
@ -669,7 +692,7 @@ def delete_product_variant(db: Session, variant_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Вариант продукта не найден"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_variant)
|
||||
db.commit()
|
||||
@ -698,14 +721,14 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если изображение отмечено как основное, сбрасываем флаг у других изображений
|
||||
if image.is_primary:
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == image.product_id,
|
||||
ProductImage.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
|
||||
|
||||
# Создаем новое изображение продукта
|
||||
db_image = ProductImage(
|
||||
product_id=image.product_id,
|
||||
@ -713,7 +736,7 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
|
||||
alt_text=image.alt_text,
|
||||
is_primary=image.is_primary
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_image)
|
||||
db.commit()
|
||||
@ -734,20 +757,20 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Изображение продукта не найдено"
|
||||
)
|
||||
|
||||
|
||||
# Если изображение отмечается как основное, сбрасываем флаг у других изображений
|
||||
if image.is_primary and not db_image.is_primary:
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == db_image.product_id,
|
||||
ProductImage.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
|
||||
|
||||
# Обновляем поля
|
||||
if image.alt_text is not None:
|
||||
db_image.alt_text = image.alt_text
|
||||
if image.is_primary is not None:
|
||||
db_image.is_primary = image.is_primary
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_image)
|
||||
@ -767,7 +790,7 @@ def delete_product_image(db: Session, image_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Изображение продукта не найдено"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_image)
|
||||
db.commit()
|
||||
@ -801,13 +824,13 @@ def create_size(db: Session, size: SizeCreate) -> Size:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Размер с кодом {size.code} уже существует"
|
||||
)
|
||||
|
||||
|
||||
db_size = Size(
|
||||
name=size.name,
|
||||
code=size.code,
|
||||
description=size.description
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_size)
|
||||
db.commit()
|
||||
@ -828,7 +851,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что код размера уникален, если он изменяется
|
||||
if size.code and size.code != db_size.code:
|
||||
existing_size = get_size_by_code(db, size.code)
|
||||
@ -837,7 +860,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Размер с кодом {size.code} уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем поля
|
||||
if size.name is not None:
|
||||
db_size.name = size.name
|
||||
@ -845,7 +868,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
db_size.code = size.code
|
||||
if size.description is not None:
|
||||
db_size.description = size.description
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_size)
|
||||
@ -865,7 +888,7 @@ def delete_size(db: Session, size_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, используется ли размер в вариантах продуктов
|
||||
variants_with_size = db.query(ProductVariant).filter(ProductVariant.size_id == size_id).count()
|
||||
if variants_with_size > 0:
|
||||
@ -873,7 +896,7 @@ def delete_size(db: Session, size_id: int) -> bool:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_size)
|
||||
db.commit()
|
||||
@ -883,4 +906,4 @@ def delete_size(db: Session, size_id: int) -> bool:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при удалении размера: {str(e)}"
|
||||
)
|
||||
)
|
||||
@ -21,7 +21,7 @@ def generate_slug(title: str) -> str:
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
# Удаляем дефисы в начале и конце
|
||||
slug = slug.strip('-')
|
||||
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
@ -35,16 +35,16 @@ def get_page_by_slug(db: Session, slug: str) -> Optional[Page]:
|
||||
|
||||
|
||||
def get_pages(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
published_only: bool = True
|
||||
) -> List[Page]:
|
||||
query = db.query(Page)
|
||||
|
||||
|
||||
if published_only:
|
||||
query = query.filter(Page.is_published == True)
|
||||
|
||||
|
||||
return query.order_by(Page.title).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@ -52,14 +52,14 @@ def create_page(db: Session, page: PageCreate) -> Page:
|
||||
# Если slug не предоставлен, генерируем его из заголовка
|
||||
if not page.slug:
|
||||
page.slug = generate_slug(page.title)
|
||||
|
||||
|
||||
# Проверяем, что страница с таким slug не существует
|
||||
if get_page_by_slug(db, page.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новую страницу
|
||||
db_page = Page(
|
||||
title=page.title,
|
||||
@ -69,7 +69,7 @@ def create_page(db: Session, page: PageCreate) -> Page:
|
||||
meta_description=page.meta_description,
|
||||
is_published=page.is_published
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_page)
|
||||
db.commit()
|
||||
@ -90,10 +90,10 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Страница не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = page.dict(exclude_unset=True)
|
||||
|
||||
update_data = page.model_dump(exclude_unset=True)
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if "slug" in update_data and update_data["slug"] != db_page.slug:
|
||||
if get_page_by_slug(db, update_data["slug"]):
|
||||
@ -101,7 +101,7 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Если заголовок изменяется и slug не предоставлен, генерируем новый slug
|
||||
if "title" in update_data and "slug" not in update_data:
|
||||
update_data["slug"] = generate_slug(update_data["title"])
|
||||
@ -111,11 +111,11 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_page, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_page)
|
||||
@ -135,7 +135,7 @@ def delete_page(db: Session, page_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Страница не найдена"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_page)
|
||||
db.commit()
|
||||
@ -152,7 +152,7 @@ def delete_page(db: Session, page_id: int) -> bool:
|
||||
def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
# Создаем новую запись аналитики
|
||||
db_log = AnalyticsLog(
|
||||
user_id=log.user_id,
|
||||
user_id=log.user_id, # Может быть None для неавторизованных пользователей
|
||||
event_type=log.event_type,
|
||||
page_url=log.page_url,
|
||||
product_id=log.product_id,
|
||||
@ -162,7 +162,7 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
referrer=log.referrer,
|
||||
additional_data=log.additional_data
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_log)
|
||||
db.commit()
|
||||
@ -177,9 +177,9 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
|
||||
|
||||
def get_analytics_logs(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
event_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
product_id: Optional[int] = None,
|
||||
@ -188,31 +188,31 @@ def get_analytics_logs(
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[AnalyticsLog]:
|
||||
query = db.query(AnalyticsLog)
|
||||
|
||||
|
||||
# Применяем фильтры
|
||||
if event_type:
|
||||
query = query.filter(AnalyticsLog.event_type == event_type)
|
||||
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AnalyticsLog.user_id == user_id)
|
||||
|
||||
|
||||
if product_id:
|
||||
query = query.filter(AnalyticsLog.product_id == product_id)
|
||||
|
||||
|
||||
if category_id:
|
||||
query = query.filter(AnalyticsLog.category_id == category_id)
|
||||
|
||||
|
||||
if start_date:
|
||||
query = query.filter(AnalyticsLog.created_at >= start_date)
|
||||
|
||||
|
||||
if end_date:
|
||||
query = query.filter(AnalyticsLog.created_at <= end_date)
|
||||
|
||||
|
||||
return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def get_analytics_report(
|
||||
db: Session,
|
||||
db: Session,
|
||||
period: str = "day",
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
@ -229,44 +229,44 @@ def get_analytics_report(
|
||||
start_date = datetime.utcnow() - timedelta(days=365)
|
||||
else:
|
||||
start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней
|
||||
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
|
||||
# Получаем все события за указанный период
|
||||
logs = db.query(AnalyticsLog).filter(
|
||||
AnalyticsLog.created_at >= start_date,
|
||||
AnalyticsLog.created_at <= end_date
|
||||
).all()
|
||||
|
||||
|
||||
# Подсчитываем статистику
|
||||
total_visits = len(logs)
|
||||
unique_visitors = len(set([log.ip_address for log in logs if log.ip_address]))
|
||||
|
||||
|
||||
# Подсчитываем просмотры страниц
|
||||
page_views = {}
|
||||
for log in logs:
|
||||
if log.event_type == "page_view" and log.page_url:
|
||||
page_views[log.page_url] = page_views.get(log.page_url, 0) + 1
|
||||
|
||||
|
||||
# Подсчитываем просмотры продуктов
|
||||
product_views = {}
|
||||
for log in logs:
|
||||
if log.event_type == "product_view" and log.product_id:
|
||||
product_id = str(log.product_id)
|
||||
product_views[product_id] = product_views.get(product_id, 0) + 1
|
||||
|
||||
|
||||
# Подсчитываем добавления в корзину
|
||||
cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart")
|
||||
|
||||
|
||||
# Подсчитываем заказы и выручку
|
||||
orders_count = sum(1 for log in logs if log.event_type == "order_created")
|
||||
|
||||
|
||||
# Для расчета выручки и среднего чека нам нужны данные о заказах
|
||||
# В данном примере мы просто используем заглушки
|
||||
revenue = 0
|
||||
average_order_value = 0
|
||||
|
||||
|
||||
# Формируем отчет
|
||||
report = {
|
||||
"period": period,
|
||||
@ -281,5 +281,5 @@ def get_analytics_report(
|
||||
"revenue": revenue,
|
||||
"average_order_value": average_order_value
|
||||
}
|
||||
|
||||
return report
|
||||
|
||||
return report
|
||||
@ -1,13 +1,15 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod
|
||||
from app.models.catalog_models import Product, ProductImage, ProductVariant, Size
|
||||
from app.models.user_models import User, UserAddress
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, OrderCreateNew
|
||||
from app.repositories import user_repo
|
||||
|
||||
|
||||
# Функции для работы с корзиной
|
||||
@ -34,7 +36,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Вариант продукта не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что продукт активен
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product or not product.is_active:
|
||||
@ -42,7 +44,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Продукт не активен или не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, есть ли уже такой товар в корзине
|
||||
existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id)
|
||||
if existing_item:
|
||||
@ -58,14 +60,14 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении элемента корзины"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новый элемент корзины
|
||||
db_cart_item = CartItem(
|
||||
user_id=user_id,
|
||||
variant_id=cart_item.variant_id,
|
||||
quantity=cart_item.quantity
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_cart_item)
|
||||
db.commit()
|
||||
@ -84,20 +86,20 @@ def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate,
|
||||
CartItem.id == cart_item_id,
|
||||
CartItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_cart_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Элемент корзины не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = cart_item.dict(exclude_unset=True)
|
||||
|
||||
update_data = cart_item.model_dump(exclude_unset=True)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_cart_item, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_cart_item)
|
||||
@ -115,13 +117,13 @@ def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool:
|
||||
CartItem.id == cart_item_id,
|
||||
CartItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_cart_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Элемент корзины не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_cart_item)
|
||||
db.commit()
|
||||
@ -152,38 +154,211 @@ def get_order(db: Session, order_id: int) -> Optional[Order]:
|
||||
return db.query(Order).filter(Order.id == order_id).first()
|
||||
|
||||
|
||||
def create_order_new(db: Session, order: OrderCreateNew, user_id: Optional[int] = None) -> Order:
|
||||
"""
|
||||
Создает новый заказ на основе новой структуры данных.
|
||||
Если пользователь не авторизован (user_id=None), создает нового пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
order: Данные для создания заказа
|
||||
user_id: ID пользователя (None, если пользователь не авторизован)
|
||||
|
||||
Returns:
|
||||
Созданный заказ
|
||||
|
||||
Raises:
|
||||
HTTPException: Если произошла ошибка при создании заказа
|
||||
"""
|
||||
# Подготовим информацию о пользователе для сохранения в JSON
|
||||
user_info_dict = {
|
||||
"first_name": order.user_info.first_name,
|
||||
"last_name": order.user_info.last_name,
|
||||
"email": order.user_info.email,
|
||||
"phone": order.user_info.phone
|
||||
}
|
||||
|
||||
# Если пользователь не авторизован, создаем нового пользователя или находим существующего
|
||||
user_created_message = None
|
||||
if user_id is None:
|
||||
try:
|
||||
user, is_new, message = user_repo.find_or_create_user_from_order(db, user_info_dict)
|
||||
user_id = user.id
|
||||
user_created_message = message
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании пользователя: {str(e)}"
|
||||
)
|
||||
|
||||
# Подготовим информацию о товарах для сохранения в JSON
|
||||
items_json = []
|
||||
for item in order.items:
|
||||
# Получаем информацию о варианте и продукте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант товара с ID {item.variant_id} не найден"
|
||||
)
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {item.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Получаем размер для варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first()
|
||||
size_name = size.name if size else "Unknown"
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
if not image:
|
||||
# Если нет основного изображения, берем первое доступное
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
image_url = image.image_url if image else None
|
||||
|
||||
# Добавляем информацию о товаре в JSON
|
||||
items_json.append({
|
||||
"product_id": product.id,
|
||||
"variant_id": variant.id,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price,
|
||||
"product_name": product.name,
|
||||
"variant_name": size_name,
|
||||
"product_image": image_url,
|
||||
"slug": product.slug
|
||||
})
|
||||
|
||||
# Преобразуем строковое значение payment_method в значение перечисления PaymentMethod
|
||||
payment_method_str = order.payment_method
|
||||
|
||||
# Прямое сопоставление с известными значениями
|
||||
if payment_method_str == "sbp":
|
||||
payment_method = PaymentMethod.SBP
|
||||
elif payment_method_str == "card":
|
||||
payment_method = PaymentMethod.CARD
|
||||
else:
|
||||
# Если не нашли соответствия, используем значение по умолчанию
|
||||
payment_method = PaymentMethod.CARD
|
||||
# Логируем ошибку
|
||||
print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: {payment_method}")
|
||||
|
||||
print(f"Метод оплаты: {payment_method} {type(payment_method)}")
|
||||
print(f"Тип payment_method_str: {type(payment_method_str)}")
|
||||
print(f"payment_method_str: {payment_method.value}")
|
||||
payment_method_str = payment_method.value
|
||||
print(f"payment_method_str: {payment_method_str} {type(payment_method_str)}")
|
||||
payment_method = payment_method_str
|
||||
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
user_id=user_id,
|
||||
status=OrderStatus.PENDING,
|
||||
|
||||
# JSON с информацией о пользователе
|
||||
user_info_json=user_info_dict,
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method=order.delivery.method,
|
||||
city=order.delivery.address.city,
|
||||
delivery_address=order.delivery.address.formatted_address or f"{order.delivery.address.city}, {order.delivery.address.street} {order.delivery.address.house}",
|
||||
|
||||
# Информация о CDEK
|
||||
cdek_info=order.delivery.cdek_info.model_dump() if order.delivery.cdek_info else None,
|
||||
|
||||
# JSON с информацией о товарах
|
||||
items_json=items_json,
|
||||
|
||||
# Информация об оплате
|
||||
payment_method=payment_method,
|
||||
notes=order.comment,
|
||||
|
||||
# Общая сумма заказа (будет обновлена после добавления товаров)
|
||||
total_amount=0
|
||||
)
|
||||
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
# Добавляем товары в заказ через разводную таблицу
|
||||
for item in order.items:
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
variant_id=item.variant_id,
|
||||
quantity=item.quantity,
|
||||
price=item.price
|
||||
)
|
||||
db.add(order_item)
|
||||
new_order.total_amount += item.price * item.quantity
|
||||
|
||||
# Проверяем, были ли добавлены элементы заказа
|
||||
if new_order.total_amount == 0:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
|
||||
# Добавляем информацию о создании пользователя в заказ
|
||||
if user_created_message:
|
||||
new_order.user_created_message = user_created_message
|
||||
|
||||
return new_order
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
|
||||
return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def get_all_orders(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[OrderStatus] = None
|
||||
) -> List[Order]:
|
||||
query = db.query(Order)
|
||||
|
||||
|
||||
if status:
|
||||
query = query.filter(Order.status == status)
|
||||
|
||||
|
||||
return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
def create_order(db: Session, order: OrderCreate, user_id: Optional[int] = None) -> Order:
|
||||
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(
|
||||
UserAddress.id == order.shipping_address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
user_id=user_id,
|
||||
@ -193,13 +368,13 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
notes=order.notes,
|
||||
total_amount=0 # Будет обновлено после добавления товаров
|
||||
)
|
||||
|
||||
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = []
|
||||
|
||||
|
||||
# Если указаны конкретные элементы корзины
|
||||
if order.cart_items:
|
||||
cart_items = db.query(CartItem).filter(
|
||||
@ -218,7 +393,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Получаем продукт для варианта
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
@ -227,10 +402,10 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
@ -243,7 +418,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
# Иначе используем все элементы корзины пользователя
|
||||
else:
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
|
||||
|
||||
# Если используем элементы корзины
|
||||
if cart_items:
|
||||
# Создаем элементы заказа из элементов корзины
|
||||
@ -252,15 +427,15 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
@ -270,7 +445,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
)
|
||||
db.add(order_item)
|
||||
new_order.total_amount += price * cart_item.quantity
|
||||
|
||||
|
||||
# Очищаем корзину пользователя после создания заказа
|
||||
if cart_items and not order.cart_items:
|
||||
# Если используем все элементы корзины, очищаем всю корзину
|
||||
@ -281,7 +456,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
CartItem.id.in_([item.id for item in cart_items]),
|
||||
CartItem.user_id == user_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
# Проверяем, были ли добавлены элементы заказа
|
||||
if new_order.total_amount == 0:
|
||||
db.rollback()
|
||||
@ -289,7 +464,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
@ -305,16 +480,16 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order:
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
order_id: ID заказа
|
||||
order_update: Данные для обновления
|
||||
is_admin: Флаг, указывающий, является ли пользователь администратором
|
||||
|
||||
|
||||
Returns:
|
||||
Обновленный заказ
|
||||
|
||||
|
||||
Raises:
|
||||
HTTPException: Если заказ не найден или нет прав на обновление
|
||||
"""
|
||||
@ -325,7 +500,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем возможность обновления статуса
|
||||
if order_update.status:
|
||||
# Если заказ уже отменен или доставлен, нельзя менять его статус
|
||||
@ -334,14 +509,14 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно изменить статус заказа из {order.status}"
|
||||
)
|
||||
|
||||
|
||||
# Если пользователь не админ, он может только отменить заказ
|
||||
if not is_admin and order_update.status != OrderStatus.CANCELLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
|
||||
# Если пользователь хочет отменить заказ, проверяем возможность отмены
|
||||
if order_update.status == OrderStatus.CANCELLED:
|
||||
if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]:
|
||||
@ -349,7 +524,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя отменить заказ, который уже отправлен или доставлен"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем другие поля для обновления
|
||||
if not is_admin:
|
||||
# Обычные пользователи могут обновлять только статус (отмена)
|
||||
@ -359,10 +534,10 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Пользователи не могут изменять поле {field}"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем поля заказа
|
||||
update_data = order_update.dict(exclude_unset=True)
|
||||
|
||||
update_data = order_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Если указан адрес доставки, проверяем его существование
|
||||
if "shipping_address_id" in update_data:
|
||||
address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first()
|
||||
@ -371,11 +546,11 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(order, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
@ -395,14 +570,14 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Только администраторы могут удалять заказы
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для удаления заказа"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_order)
|
||||
db.commit()
|
||||
@ -423,29 +598,29 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
result = []
|
||||
|
||||
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
size_name = size.name if size else ''
|
||||
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
product_image = None
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
@ -453,10 +628,10 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first()
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
|
||||
# Определяем цену товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Формируем элемент корзины с деталями
|
||||
cart_item_details = {
|
||||
"id": cart_item.id,
|
||||
@ -473,9 +648,9 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
"variant_name": size_name,
|
||||
"total_price": price * cart_item.quantity
|
||||
}
|
||||
|
||||
|
||||
result.append(cart_item_details)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -487,12 +662,12 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
return None
|
||||
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
user_email = user.email if user else None
|
||||
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None
|
||||
|
||||
|
||||
# Получаем адрес доставки
|
||||
shipping_address = None
|
||||
if order.shipping_address_id:
|
||||
@ -508,41 +683,41 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"country": address.country,
|
||||
"is_default": address.is_default
|
||||
}
|
||||
|
||||
|
||||
# Получаем элементы заказа с информацией о продуктах
|
||||
items = []
|
||||
order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
|
||||
|
||||
|
||||
for item in order_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
variant_name = None
|
||||
size_name = None
|
||||
|
||||
|
||||
if variant:
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
if size:
|
||||
size_name = size.name
|
||||
variant_name = f"{size.name}"
|
||||
|
||||
|
||||
# Получаем информацию о продукте
|
||||
product = None
|
||||
product_name = "Удаленный продукт"
|
||||
product_image = None
|
||||
|
||||
|
||||
if variant:
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
|
||||
|
||||
if product:
|
||||
product_name = product.name
|
||||
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
@ -550,10 +725,10 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
any_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
|
||||
# Добавляем информацию об элементе заказа
|
||||
items.append({
|
||||
"id": item.id,
|
||||
@ -568,17 +743,43 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"size": size_name,
|
||||
"total_price": item.price * item.quantity
|
||||
})
|
||||
|
||||
|
||||
# Получаем информацию о пользователе из JSON
|
||||
user_info = order.user_info_json or {}
|
||||
|
||||
# Формируем результат
|
||||
return {
|
||||
result = {
|
||||
"id": order.id,
|
||||
"user_id": order.user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"status": order.status,
|
||||
"total_amount": order.total_amount,
|
||||
|
||||
# Информация о пользователе из JSON
|
||||
"user_info_json": order.user_info_json,
|
||||
|
||||
# Извлекаем информацию о пользователе для отображения
|
||||
"first_name": user_info.get("first_name", ""),
|
||||
"last_name": user_info.get("last_name", ""),
|
||||
"email": user_info.get("email", user_email),
|
||||
"phone": user_info.get("phone", ""),
|
||||
"user_email": user_email, # Для обратной совместимости
|
||||
"user_name": user_name, # Для обратной совместимости
|
||||
|
||||
# Информация о доставке
|
||||
"delivery_method": order.delivery_method,
|
||||
"city": order.city,
|
||||
"delivery_address": order.delivery_address,
|
||||
"cdek_info": order.cdek_info,
|
||||
"courier_info": order.courier_info,
|
||||
|
||||
# Информация о товарах из JSON
|
||||
"items_json": order.items_json,
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
"shipping_address_id": order.shipping_address_id,
|
||||
"shipping_address": shipping_address,
|
||||
|
||||
# Дополнительная информация
|
||||
"payment_method": order.payment_method,
|
||||
"payment_details": order.payment_details,
|
||||
"tracking_number": order.tracking_number,
|
||||
@ -586,4 +787,6 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"items": items
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@ -1,7 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.models.user_models import User, UserAddress
|
||||
from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate
|
||||
@ -14,11 +16,11 @@ def get_user(db: Session, user_id: int) -> Optional[User]:
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
||||
print(email)
|
||||
# print(email)
|
||||
users = db.query(User).all()
|
||||
print(users)
|
||||
for user in users:
|
||||
print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}")
|
||||
# print(users)
|
||||
# for user in users:
|
||||
# print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}")
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
@ -33,7 +35,7 @@ def create_user(db: Session, user: UserCreate) -> User:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Создаем нового пользователя
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = User(
|
||||
@ -45,7 +47,7 @@ def create_user(db: Session, user: UserCreate) -> User:
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
@ -66,17 +68,17 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = user.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если предоставлен новый пароль, хешируем его
|
||||
if "password" in update_data and update_data["password"]:
|
||||
update_data["password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
|
||||
# Удаляем поле password_confirm, если оно есть
|
||||
update_data.pop("password_confirm", None)
|
||||
|
||||
|
||||
# Проверяем уникальность email, если он изменяется
|
||||
if "email" in update_data and update_data["email"] != db_user.email:
|
||||
if get_user_by_email(db, update_data["email"]):
|
||||
@ -84,11 +86,11 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_user, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
@ -108,7 +110,7 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
@ -130,6 +132,57 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
return user
|
||||
|
||||
|
||||
def find_or_create_user_from_order(db: Session, user_info: Dict[str, Any]) -> Tuple[User, bool, str]:
|
||||
"""
|
||||
Ищет пользователя по email или создает нового на основе данных заказа.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user_info: Информация о пользователе из заказа
|
||||
|
||||
Returns:
|
||||
Кортеж (пользователь, создан_новый, сообщение)
|
||||
"""
|
||||
email = user_info.get("email")
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email обязателен для создания пользователя"
|
||||
)
|
||||
|
||||
# Ищем пользователя по email
|
||||
existing_user = get_user_by_email(db, email)
|
||||
if existing_user:
|
||||
return existing_user, False, "Пользователь с таким email уже существует"
|
||||
|
||||
# Генерируем случайный пароль
|
||||
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Создаем нового пользователя
|
||||
new_user = User(
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
first_name=user_info.get("first_name", ""),
|
||||
last_name=user_info.get("last_name", ""),
|
||||
phone=user_info.get("phone", ""),
|
||||
is_active=True,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user, True, f"Создан новый пользователь с email {email}"
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при создании пользователя"
|
||||
)
|
||||
|
||||
|
||||
# Функции для работы с адресами пользователей
|
||||
def get_address(db: Session, address_id: int) -> Optional[UserAddress]:
|
||||
return db.query(UserAddress).filter(UserAddress.id == address_id).first()
|
||||
@ -146,7 +199,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
|
||||
UserAddress.user_id == user_id,
|
||||
UserAddress.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
|
||||
db_address = UserAddress(
|
||||
user_id=user_id,
|
||||
address_line1=address.address_line1,
|
||||
@ -157,7 +210,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
|
||||
country=address.country,
|
||||
is_default=address.is_default
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_address)
|
||||
db.commit()
|
||||
@ -178,16 +231,16 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
|
||||
UserAddress.id == address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = address.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя
|
||||
if "is_default" in update_data and update_data["is_default"]:
|
||||
db.query(UserAddress).filter(
|
||||
@ -195,11 +248,11 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
|
||||
UserAddress.id != address_id,
|
||||
UserAddress.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_address, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_address)
|
||||
@ -217,13 +270,13 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
|
||||
UserAddress.id == address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_address)
|
||||
db.commit()
|
||||
@ -245,10 +298,10 @@ def update_password(db: Session, user_id: int, new_password: str) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
hashed_password = get_password_hash(new_password)
|
||||
db_user.password = hashed_password
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
return True
|
||||
@ -264,14 +317,14 @@ def create_password_reset_token(db: Session, user_id: int) -> str:
|
||||
"""Создает токен для сброса пароля"""
|
||||
import secrets
|
||||
import datetime
|
||||
|
||||
|
||||
# В реальном приложении здесь должна быть модель для токенов сброса пароля
|
||||
# Для примера просто генерируем случайный токен
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
|
||||
# и временем истечения срока действия
|
||||
|
||||
|
||||
return token
|
||||
|
||||
|
||||
@ -279,7 +332,7 @@ def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
|
||||
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
|
||||
# В реальном приложении проверяем токен в базе данных
|
||||
# и его срок действия
|
||||
|
||||
|
||||
# Для примера просто возвращаем фиктивный ID пользователя
|
||||
# В реальном приложении это должна быть проверка в базе данных
|
||||
return 1 # Фиктивный ID пользователя
|
||||
return 1 # Фиктивный ID пользователя
|
||||
@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File, Form, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi.responses import JSONResponse
|
||||
@ -17,6 +17,7 @@ from app.schemas.catalog_schemas import (
|
||||
ProductCreateComplete, ProductUpdateComplete
|
||||
)
|
||||
from app.models.user_models import User as UserModel
|
||||
from app.models.catalog_models import Category, Size, Collection
|
||||
from app.repositories.catalog_repo import get_products, get_product_by_slug
|
||||
|
||||
# Роутер для каталога
|
||||
@ -42,8 +43,64 @@ async def delete_collection_endpoint(collection_id: int, current_user: UserModel
|
||||
|
||||
|
||||
@catalog_router.get("/collections", response_model=Dict[str, Any])
|
||||
async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
return services.get_collections(db, skip, limit)
|
||||
async def get_collections_endpoint(
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Формируем фильтры для Meilisearch
|
||||
filters = []
|
||||
|
||||
if is_active is not None:
|
||||
filters.append(f"is_active = {str(is_active).lower()}")
|
||||
|
||||
# Используем Meilisearch для поиска коллекций
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Объединяем фильтры в строку
|
||||
filter_str = " AND ".join(filters) if filters else None
|
||||
|
||||
# Выполняем поиск в Meilisearch
|
||||
result = meilisearch_service.search_collections(
|
||||
query=search or "",
|
||||
filters=filter_str,
|
||||
limit=limit,
|
||||
offset=skip
|
||||
)
|
||||
|
||||
# Если поиск в Meilisearch успешен, возвращаем результаты
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"collections": result["collections"],
|
||||
"total": result["total"]
|
||||
}
|
||||
|
||||
# Если поиск в Meilisearch не удался, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
|
||||
# Получаем коллекции из базы данных
|
||||
collections_db = db.query(Collection).all()
|
||||
|
||||
# Преобразуем коллекции в список словарей
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema
|
||||
collections_list = [CollectionSchema.model_validate(collection).model_dump() for collection in collections_db]
|
||||
|
||||
# Синхронизируем коллекции с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_collections
|
||||
sync_collections(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync collections with Meilisearch: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"collections": collections_list,
|
||||
"total": len(collections_list)
|
||||
}
|
||||
|
||||
|
||||
#########################
|
||||
@ -65,9 +122,72 @@ async def delete_category_endpoint(category_id: int, current_user: UserModel = D
|
||||
return services.delete_category(db, category_id)
|
||||
|
||||
|
||||
@catalog_router.get("/categories", response_model=List[Dict[str, Any]])
|
||||
async def get_categories_tree(db: Session = Depends(get_db)):
|
||||
return services.get_category_tree(db)
|
||||
@catalog_router.get("/categories", response_model=Dict[str, Any])
|
||||
async def get_categories_endpoint(
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
parent_id: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Формируем фильтры для Meilisearch
|
||||
filters = []
|
||||
|
||||
if parent_id is not None:
|
||||
filters.append(f"parent_id = {parent_id}")
|
||||
|
||||
if is_active is not None:
|
||||
filters.append(f"is_active = {str(is_active).lower()}")
|
||||
|
||||
# Используем Meilisearch для поиска категорий
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Объединяем фильтры в строку
|
||||
filter_str = " AND ".join(filters) if filters else None
|
||||
|
||||
# Выполняем поиск в Meilisearch
|
||||
result = meilisearch_service.search_categories(
|
||||
query=search or "",
|
||||
filters=filter_str,
|
||||
limit=limit,
|
||||
offset=skip
|
||||
)
|
||||
|
||||
# Добавляем логирование для отладки
|
||||
logging.info(f"Meilisearch search result: {result}")
|
||||
|
||||
# Если поиск в Meilisearch успешен, возвращаем результаты
|
||||
if result["success"] and result["categories"]:
|
||||
return {
|
||||
"success": True,
|
||||
"categories": result["categories"],
|
||||
"total": result["total"]
|
||||
}
|
||||
|
||||
# Если поиск в Meilisearch не удался, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
|
||||
# Получаем категории из базы данных
|
||||
categories = db.query(Category).all()
|
||||
|
||||
# Преобразуем категории в список словарей
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
categories_list = [CategorySchema.model_validate(category).model_dump() for category in categories]
|
||||
|
||||
# Синхронизируем категории с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_categories
|
||||
sync_categories(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync categories with Meilisearch: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"categories": categories_list,
|
||||
"total": len(categories_list)
|
||||
}
|
||||
|
||||
|
||||
#########################
|
||||
@ -76,40 +196,118 @@ async def get_categories_tree(db: Session = Depends(get_db)):
|
||||
|
||||
@catalog_router.post("/products", response_model=Dict[str, Any])
|
||||
async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
# Используем синхронную версию для создания продукта
|
||||
return services.create_product(db, product)
|
||||
|
||||
|
||||
@catalog_router.put("/products/{product_id}", response_model=Dict[str, Any])
|
||||
async def update_product_endpoint(product_id: int, product: ProductUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
# Используем синхронную версию для обновления продукта
|
||||
return services.update_product(db, product_id, product)
|
||||
|
||||
|
||||
@catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any])
|
||||
async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
return services.delete_product(db, product_id)
|
||||
# Используем синхронную версию для удаления продукта
|
||||
logging.warning(f"Удаление продукта с ID {product_id} в маршруте")
|
||||
result = services.delete_product(db, product_id)
|
||||
|
||||
# Если удаление не удалось и есть сообщение об ошибке, возвращаем соответствующий статус ошибки
|
||||
if not result.get("success") and "error" in result:
|
||||
if "заказах" in result.get("error", ""):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"])
|
||||
elif "не найден" in result.get("error", ""):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=result["error"])
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@catalog_router.get("/products/{product_id}", response_model=Dict[str, Any])
|
||||
async def get_product_details_endpoint(product_id: int, db: Session = Depends(get_db)):
|
||||
return services.get_product_details(db, product_id)
|
||||
async def get_product_details_endpoint(*, product_id: int, db: Session = Depends(get_db)):
|
||||
# Сначала пробуем найти продукт в Meilisearch
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Используем поиск по ID
|
||||
result = meilisearch_service.get_product(product_id)
|
||||
|
||||
# Если продукт найден в Meilisearch, возвращаем его
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"product": result["product"]
|
||||
}
|
||||
|
||||
# Если продукт не найден в Meilisearch, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
|
||||
# Проверяем существование продукта
|
||||
from app.models.catalog_models import Product
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
# Получаем детали продукта
|
||||
product_details = services.get_product_details(db, product_id)
|
||||
|
||||
# Синхронизируем продукт с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_products
|
||||
sync_products(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
|
||||
|
||||
return {"success": True, "product": product_details}
|
||||
|
||||
|
||||
@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any])
|
||||
async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)):
|
||||
async def get_product_by_slug_endpoint(*, slug: str, db: Session = Depends(get_db)):
|
||||
# Сначала пробуем найти продукт в Meilisearch
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Используем поиск по slug
|
||||
result = meilisearch_service.get_product_by_slug(slug)
|
||||
|
||||
# Если продукт найден в Meilisearch, возвращаем его
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"product": result["product"]
|
||||
}
|
||||
|
||||
# Если продукт не найден в Meilisearch, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
|
||||
# Используем синхронную версию для получения продукта по slug
|
||||
product = get_product_by_slug(db, slug)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
detail=f"Продукт с slug {slug} не найден"
|
||||
)
|
||||
return services.get_product_details(db, product.id)
|
||||
|
||||
# Получаем детали продукта
|
||||
product_details = services.get_product_details(db, product.id)
|
||||
|
||||
# Синхронизируем продукт с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_products
|
||||
sync_products(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
|
||||
|
||||
return {"success": True, "product": product_details}
|
||||
|
||||
|
||||
@catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any])
|
||||
async def add_product_variant_endpoint(
|
||||
product_id: int,
|
||||
variant: ProductVariantCreate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
product_id: int,
|
||||
variant: ProductVariantCreate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Убедимся, что product_id в пути совпадает с product_id в данных
|
||||
@ -120,9 +318,9 @@ async def add_product_variant_endpoint(
|
||||
|
||||
@catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any])
|
||||
async def update_product_variant_endpoint(
|
||||
variant_id: int,
|
||||
variant: ProductVariantUpdate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
variant_id: int,
|
||||
variant: ProductVariantUpdate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.update_product_variant(db, variant_id, variant)
|
||||
@ -130,8 +328,8 @@ async def update_product_variant_endpoint(
|
||||
|
||||
@catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any])
|
||||
async def delete_product_variant_endpoint(
|
||||
variant_id: int,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
variant_id: int,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.delete_product_variant(db, variant_id)
|
||||
@ -180,7 +378,7 @@ async def upload_product_image_endpoint(
|
||||
logging.info(f"Начало обработки загрузки изображения для продукта {product_id}")
|
||||
logging.info(f"Данные файла: имя={file.filename}, тип={file.content_type}, размер={file.size if hasattr(file, 'size') else 'unknown'}")
|
||||
logging.info(f"is_primary: {is_primary}")
|
||||
|
||||
|
||||
# Удостоверяемся, что продукт существует
|
||||
from app.models.catalog_models import Product
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
@ -196,7 +394,7 @@ async def upload_product_image_endpoint(
|
||||
logging.info("Вызов сервиса upload_product_image")
|
||||
result = services.upload_product_image(
|
||||
db, product_id, file, is_primary, alt_text="")
|
||||
|
||||
|
||||
logging.info(f"Результат загрузки изображения: {result}")
|
||||
|
||||
# Возвращаем успешный ответ с данными изображения
|
||||
@ -216,7 +414,7 @@ async def upload_product_image_endpoint(
|
||||
error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
|
||||
# Возвращаем ошибку с кодом 400
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@ -226,9 +424,9 @@ async def upload_product_image_endpoint(
|
||||
|
||||
@catalog_router.put("/images/{image_id}", response_model=Dict[str, Any])
|
||||
async def update_product_image_endpoint(
|
||||
image_id: int,
|
||||
image: ProductImageUpdate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
image_id: int,
|
||||
image: ProductImageUpdate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.update_product_image(db, image_id, image)
|
||||
@ -239,78 +437,231 @@ async def delete_product_image_endpoint(image_id: int, current_user: UserModel =
|
||||
return services.delete_product_image(db, image_id)
|
||||
|
||||
|
||||
@catalog_router.get("/products", response_model=List[Product])
|
||||
@catalog_router.get("/products", response_model=Dict[str, Any])
|
||||
async def get_products_endpoint(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
category_id: Optional[int] = None,
|
||||
collection_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
is_active: Optional[bool] = True,
|
||||
include_variants: Optional[bool] = False,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = "asc",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants)
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
return [Product.from_orm(product) for product in products]
|
||||
# Формируем фильтры для Meilisearch
|
||||
filters = []
|
||||
|
||||
if category_id is not None:
|
||||
filters.append(f"category_id = {category_id}")
|
||||
|
||||
if collection_id is not None:
|
||||
filters.append(f"collection_id = {collection_id}")
|
||||
|
||||
if is_active is not None:
|
||||
filters.append(f"is_active = {str(is_active).lower()}")
|
||||
|
||||
if min_price is not None:
|
||||
filters.append(f"price >= {min_price}")
|
||||
|
||||
if max_price is not None:
|
||||
filters.append(f"price <= {max_price}")
|
||||
|
||||
# Формируем параметры сортировки
|
||||
sort = None
|
||||
if sort_by:
|
||||
sort = [f"{sort_by}:{sort_order}"]
|
||||
|
||||
# Используем Meilisearch для поиска продуктов
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Объединяем фильтры в строку
|
||||
filter_str = " AND ".join(filters) if filters else None
|
||||
|
||||
# Выполняем поиск в Meilisearch
|
||||
result = meilisearch_service.search_products(
|
||||
query=search or "",
|
||||
filters=filter_str,
|
||||
sort=sort,
|
||||
limit=limit,
|
||||
offset=skip
|
||||
)
|
||||
|
||||
# Если поиск в Meilisearch успешен, возвращаем результаты
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"products": result["products"],
|
||||
"total": result["total"],
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
# Если поиск в Meilisearch не удался, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active)
|
||||
products_list = [services.format_product(product) for product in products]
|
||||
|
||||
# Синхронизируем продукты с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_products
|
||||
sync_products(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"products": products_list,
|
||||
"total": len(products_list),
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
|
||||
# Маршруты для размеров
|
||||
@catalog_router.get("/sizes", response_model=List[Size])
|
||||
def get_sizes(
|
||||
skip: int = 0,
|
||||
@catalog_router.get("/sizes", response_model=Dict[str, Any])
|
||||
async def get_sizes(*,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Получить список всех размеров"""
|
||||
return services.get_sizes(db, skip, limit)
|
||||
# Используем Meilisearch для поиска размеров
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Выполняем поиск в Meilisearch
|
||||
result = meilisearch_service.search_sizes(
|
||||
query=search or "",
|
||||
filters=None,
|
||||
limit=limit,
|
||||
offset=skip
|
||||
)
|
||||
|
||||
# Если поиск в Meilisearch успешен, возвращаем результаты
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"sizes": result["sizes"],
|
||||
"total": result["total"]
|
||||
}
|
||||
|
||||
# Если поиск в Meilisearch не удался, используем старый метод
|
||||
logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
|
||||
|
||||
# Получаем размеры из базы данных
|
||||
from app.models.catalog_models import Size
|
||||
sizes_db = db.query(Size).all()
|
||||
|
||||
# Преобразуем размеры в список словарей
|
||||
from app.schemas.catalog_schemas import Size as SizeSchema
|
||||
sizes_list = [SizeSchema.model_validate(size).model_dump() for size in sizes_db]
|
||||
|
||||
# Синхронизируем размеры с Meilisearch для будущих запросов
|
||||
try:
|
||||
from app.scripts.sync_meilisearch import sync_sizes
|
||||
sync_sizes(db)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to sync sizes with Meilisearch: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sizes": sizes_list,
|
||||
"total": len(sizes_list)
|
||||
}
|
||||
|
||||
|
||||
@catalog_router.get("/sizes/{size_id}", response_model=Size)
|
||||
def get_size(
|
||||
@catalog_router.get("/sizes/{size_id}", response_model=Dict[str, Any])
|
||||
async def get_size(
|
||||
size_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Получить размер по ID"""
|
||||
return services.get_size(db, size_id)
|
||||
# Пробуем получить размер из Meilisearch
|
||||
from app.services import meilisearch_service
|
||||
result = meilisearch_service.get_size(size_id)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"size": result["size"]
|
||||
}
|
||||
|
||||
# Если размер не найден в Meilisearch, получаем его из базы данных
|
||||
size = services.get_size(db, size_id)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в словарь
|
||||
from app.schemas.catalog_schemas import Size as SizeSchema
|
||||
size_dict = SizeSchema.model_validate(size).model_dump()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"size": size_dict
|
||||
}
|
||||
|
||||
|
||||
@catalog_router.post("/sizes", response_model=Size, status_code=status.HTTP_201_CREATED)
|
||||
@catalog_router.post("/sizes", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
|
||||
def create_size(
|
||||
size: SizeCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Создать новый размер"""
|
||||
return services.create_size(db, size)
|
||||
new_size = services.create_size(db, size)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в словарь
|
||||
from app.schemas.catalog_schemas import Size as SizeSchema
|
||||
size_dict = SizeSchema.model_validate(new_size).model_dump()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"size": size_dict
|
||||
}
|
||||
|
||||
|
||||
@catalog_router.put("/sizes/{size_id}", response_model=Size)
|
||||
@catalog_router.put("/sizes/{size_id}", response_model=Dict[str, Any])
|
||||
def update_size(
|
||||
size_id: int,
|
||||
size: SizeUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Обновить размер"""
|
||||
return services.update_size(db, size_id, size)
|
||||
updated_size = services.update_size(db, size_id, size)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в словарь
|
||||
from app.schemas.catalog_schemas import Size as SizeSchema
|
||||
size_dict = SizeSchema.model_validate(updated_size).model_dump()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"size": size_dict
|
||||
}
|
||||
|
||||
|
||||
@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, bool])
|
||||
@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, Any])
|
||||
def delete_size(
|
||||
size_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Удалить размер"""
|
||||
# Удаляем размер из базы данных
|
||||
success = services.delete_size(db, size_id)
|
||||
|
||||
# Если удаление прошло успешно, удаляем размер из Meilisearch
|
||||
if success:
|
||||
from app.services import meilisearch_service
|
||||
meilisearch_service.delete_size(size_id)
|
||||
|
||||
return {"success": success}
|
||||
|
||||
|
||||
# Маршруты для комплексного создания и обновления продуктов
|
||||
@catalog_router.post("/products/complete", response_model=Dict[str, Any])
|
||||
async def create_product_complete_endpoint(
|
||||
product: ProductCreateComplete,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
product: ProductCreateComplete,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@ -321,12 +672,13 @@ async def create_product_complete_endpoint(
|
||||
|
||||
@catalog_router.put("/products/{product_id}/complete", response_model=Dict[str, Any])
|
||||
async def update_product_complete_endpoint(
|
||||
product_id: int,
|
||||
product: ProductUpdateComplete,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
product_id: int,
|
||||
product: ProductUpdateComplete,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Обновление продукта вместе с его вариантами и изображениями в одном запросе.
|
||||
"""
|
||||
return services.update_product_complete(db, product_id, product)
|
||||
result = services.update_product_complete(db, product_id, product)
|
||||
return result
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
from app.core import get_db, get_current_active_user
|
||||
from app import services
|
||||
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order
|
||||
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order, OrderCreateNew
|
||||
from app.models.user_models import User as UserModel
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
@ -13,31 +13,61 @@ order_router = APIRouter(prefix="/orders", tags=["Заказы"])
|
||||
|
||||
@order_router.post("/", response_model=Dict[str, Any])
|
||||
async def create_order_endpoint(
|
||||
order: OrderCreate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order: OrderCreate,
|
||||
# current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создает новый заказ.
|
||||
|
||||
Создает новый заказ (старый формат).
|
||||
|
||||
- **shipping_address_id**: ID адреса доставки
|
||||
- **payment_method**: Способ оплаты
|
||||
- **notes**: Примечания к заказу (опционально)
|
||||
- **cart_items**: Список ID элементов корзины (опционально)
|
||||
- **items**: Прямые элементы заказа (опционально)
|
||||
"""
|
||||
return services.create_order(db, current_user.id, order)
|
||||
return services.create_order(db, None, order)
|
||||
|
||||
|
||||
@order_router.post("/new", response_model=Dict[str, Any])
|
||||
async def create_order_new_endpoint(
|
||||
order: OrderCreateNew,
|
||||
db: Session = Depends(get_db),
|
||||
# current_user: Optional[UserModel] = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Создает новый заказ с новой структурой данных.
|
||||
Если пользователь не авторизован, создает нового пользователя на основе данных заказа.
|
||||
|
||||
- **user_info**: Информация о пользователе (имя, фамилия, email, телефон)
|
||||
- **delivery**: Информация о доставке (метод, адрес, информация о CDEK)
|
||||
- **items**: Товары в заказе
|
||||
- **payment_method**: Способ оплаты
|
||||
- **comment**: Комментарий к заказу (опционально)
|
||||
"""
|
||||
# Если пользователь авторизован, используем его ID, иначе передаем None
|
||||
user_id = None # current_user.id if current_user else None
|
||||
|
||||
# Проверяем наличие email в данных заказа
|
||||
if not order.user_info.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email обязателен для оформления заказа"
|
||||
)
|
||||
|
||||
# Создаем заказ (если пользователь не авторизован, будет создан новый пользователь)
|
||||
return services.create_order_new(db, user_id, order)
|
||||
|
||||
|
||||
@order_router.get("/{order_id}", response_model=Dict[str, Any])
|
||||
async def get_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.get_order(db, current_user.id, order_id, current_user.is_admin)
|
||||
@ -45,14 +75,14 @@ async def get_order_endpoint(
|
||||
|
||||
@order_router.put("/{order_id}", response_model=Dict[str, Any])
|
||||
async def update_order_endpoint(
|
||||
order_id: int,
|
||||
order: OrderUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
order: OrderUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
- **status**: Новый статус заказа (опционально, только для админов)
|
||||
- **shipping_address_id**: ID нового адреса доставки (опционально, только для админов)
|
||||
@ -66,13 +96,13 @@ async def update_order_endpoint(
|
||||
|
||||
@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any])
|
||||
async def cancel_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Отменяет заказ.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.cancel_order(db, current_user.id, order_id)
|
||||
@ -80,15 +110,15 @@ async def cancel_order_endpoint(
|
||||
|
||||
@order_router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def get_orders_endpoint(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[OrderStatus] = None,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает список заказов пользователя.
|
||||
|
||||
|
||||
- **skip**: Количество пропускаемых записей
|
||||
- **limit**: Максимальное количество записей
|
||||
- **status**: Фильтр по статусу заказа (опционально)
|
||||
@ -98,6 +128,6 @@ async def get_orders_endpoint(
|
||||
orders = services.order_repo.get_all_orders(db, skip, limit, status)
|
||||
else:
|
||||
orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit)
|
||||
|
||||
|
||||
# Получаем полную информацию о каждом заказе
|
||||
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
|
||||
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
@ -62,7 +62,107 @@ class OrderItemWithProduct(OrderItem):
|
||||
variant_name: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для заказов
|
||||
# Схемы для информации о пользователе
|
||||
class UserInfoBase(BaseModel):
|
||||
first_name: str
|
||||
last_name: Optional[str] = ""
|
||||
email: str
|
||||
phone: str
|
||||
|
||||
|
||||
# Схемы для адреса доставки
|
||||
class AddressBase(BaseModel):
|
||||
city: str
|
||||
street: Optional[str] = ""
|
||||
house: Optional[str] = ""
|
||||
apartment: Optional[str] = ""
|
||||
postal_code: Optional[str] = ""
|
||||
formatted_address: Optional[str] = ""
|
||||
|
||||
|
||||
# Схемы для информации о доставке CDEK
|
||||
class CDEKPvzInfo(BaseModel):
|
||||
city_code: Optional[int] = None
|
||||
city: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
have_cashless: Optional[bool] = None
|
||||
have_cash: Optional[bool] = None
|
||||
allowed_cod: Optional[bool] = None
|
||||
is_dressing_room: Optional[bool] = None
|
||||
code: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
work_time: Optional[str] = None
|
||||
location: Optional[List[float]] = None
|
||||
weight_min: Optional[int] = None
|
||||
weight_max: Optional[int] = None
|
||||
dimensions: Optional[Any] = None
|
||||
|
||||
|
||||
class CDEKTariffInfo(BaseModel):
|
||||
tariff_code: Optional[int] = None
|
||||
tariff_name: Optional[str] = None
|
||||
tariff_description: Optional[str] = None
|
||||
delivery_mode: Optional[int] = None
|
||||
delivery_sum: Optional[int] = None
|
||||
period_min: Optional[int] = None
|
||||
period_max: Optional[int] = None
|
||||
calendar_min: Optional[int] = None
|
||||
calendar_max: Optional[int] = None
|
||||
delivery_date_range: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class CDEKInfo(BaseModel):
|
||||
pvz: Optional[CDEKPvzInfo] = None
|
||||
tariff: Optional[CDEKTariffInfo] = None
|
||||
delivery_type: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для информации о доставке курьером
|
||||
class CourierInfo(BaseModel):
|
||||
geo_lat: Optional[str] = None
|
||||
geo_lon: Optional[str] = None
|
||||
fias_id: Optional[str] = None
|
||||
kladr_id: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для информации о доставке
|
||||
class DeliveryInfo(BaseModel):
|
||||
method: str # cdek или courier
|
||||
address: AddressBase
|
||||
cdek_info: Optional[CDEKInfo] = None
|
||||
|
||||
|
||||
# Схемы для элементов заказа в новом формате
|
||||
class OrderItemNew(BaseModel):
|
||||
product_id: int
|
||||
variant_id: int
|
||||
quantity: int
|
||||
price: float
|
||||
|
||||
|
||||
# Схемы для заказов в новом формате
|
||||
class OrderCreateNew(BaseModel):
|
||||
user_info: UserInfoBase
|
||||
delivery: DeliveryInfo
|
||||
items: List[OrderItemNew]
|
||||
payment_method: str
|
||||
comment: Optional[str] = ""
|
||||
|
||||
@field_validator('payment_method')
|
||||
def validate_payment_method(cls, v):
|
||||
# Проверяем, что значение payment_method соответствует одному из допустимых значений
|
||||
valid_methods = ["sbp", "card"]
|
||||
if v.lower() not in valid_methods:
|
||||
# Если не соответствует, преобразуем к значению по умолчанию
|
||||
return "card"
|
||||
return v.lower() # Возвращаем значение в нижнем регистре
|
||||
|
||||
|
||||
# Старые схемы для заказов (для обратной совместимости)
|
||||
class OrderBase(BaseModel):
|
||||
shipping_address_id: Optional[int] = None
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
@ -82,12 +182,35 @@ class OrderUpdate(BaseModel):
|
||||
tracking_number: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# Новые поля
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class Order(OrderBase):
|
||||
id: int
|
||||
user_id: int
|
||||
status: OrderStatus
|
||||
total_amount: float
|
||||
|
||||
# Информация о пользователе (из JSON)
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о товарах (из JSON)
|
||||
items_json: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Дополнительная информация
|
||||
payment_details: Optional[str] = None
|
||||
tracking_number: Optional[str] = None
|
||||
created_at: datetime
|
||||
@ -119,7 +242,25 @@ class OrderWithDetails(BaseModel):
|
||||
user_id: int
|
||||
status: OrderStatus
|
||||
total_amount: float
|
||||
|
||||
# Информация о пользователе (из JSON)
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о товарах (из JSON)
|
||||
items_json: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
shipping_address_id: Optional[int] = None
|
||||
shipping_address: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Дополнительная информация
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
payment_details: Optional[str] = None
|
||||
tracking_number: Optional[str] = None
|
||||
@ -127,5 +268,4 @@ class OrderWithDetails(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
user_email: Optional[str] = None
|
||||
shipping_address: Optional[Dict[str, Any]] = None
|
||||
items: List[Dict[str, Any]] = []
|
||||
items: List[Dict[str, Any]] = []
|
||||
1
backend/app/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
BIN
backend/app/scripts/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc
Normal file
249
backend/app/scripts/sync_meilisearch.py
Normal file
@ -0,0 +1,249 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core import SessionLocal, engine
|
||||
from app.models.catalog_models import Product, Category, Collection, Size, ProductVariant, ProductImage
|
||||
from app.services import meilisearch_service
|
||||
from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema, Size as SizeSchema
|
||||
|
||||
# Настройка логгера
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_product_for_meilisearch(product, variants, images):
|
||||
"""
|
||||
Форматирует продукт для индексации в Meilisearch.
|
||||
"""
|
||||
# Преобразуем объект SQLAlchemy в словарь
|
||||
product_dict = {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": product.price,
|
||||
"discount_price": product.discount_price,
|
||||
"care_instructions": product.care_instructions,
|
||||
"is_active": product.is_active,
|
||||
"category_id": product.category_id,
|
||||
"collection_id": product.collection_id,
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
"variants": [],
|
||||
"images": []
|
||||
}
|
||||
|
||||
# Добавляем варианты продукта
|
||||
for variant in variants:
|
||||
variant_dict = {
|
||||
"id": variant.id,
|
||||
"size_id": variant.size_id,
|
||||
"sku": variant.sku,
|
||||
"stock": variant.stock,
|
||||
"is_active": variant.is_active
|
||||
}
|
||||
|
||||
# Добавляем информацию о размере, если она доступна
|
||||
if variant.size:
|
||||
variant_dict["size"] = {
|
||||
"id": variant.size.id,
|
||||
"name": variant.size.name,
|
||||
"code": variant.size.code
|
||||
}
|
||||
|
||||
product_dict["variants"].append(variant_dict)
|
||||
|
||||
# Добавляем изображения продукта
|
||||
for image in images:
|
||||
image_dict = {
|
||||
"id": image.id,
|
||||
"image_url": image.image_url,
|
||||
"alt_text": image.alt_text,
|
||||
"is_primary": image.is_primary
|
||||
}
|
||||
product_dict["images"].append(image_dict)
|
||||
|
||||
# Добавляем основное изображение в корень продукта для удобства
|
||||
if image.is_primary:
|
||||
product_dict["primary_image"] = image.image_url
|
||||
|
||||
return product_dict
|
||||
|
||||
|
||||
def format_category_for_meilisearch(category):
|
||||
"""
|
||||
Форматирует категорию для индексации в Meilisearch.
|
||||
"""
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"description": category.description,
|
||||
"parent_id": category.parent_id,
|
||||
"is_active": category.is_active,
|
||||
"created_at": category.created_at.isoformat() if category.created_at else None,
|
||||
"updated_at": category.updated_at.isoformat() if category.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
def format_collection_for_meilisearch(collection):
|
||||
"""
|
||||
Форматирует коллекцию для индексации в Meilisearch.
|
||||
"""
|
||||
return {
|
||||
"id": collection.id,
|
||||
"name": collection.name,
|
||||
"slug": collection.slug,
|
||||
"description": collection.description,
|
||||
"is_active": collection.is_active,
|
||||
"created_at": collection.created_at.isoformat() if collection.created_at else None,
|
||||
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
def format_size_for_meilisearch(size):
|
||||
"""
|
||||
Форматирует размер для индексации в Meilisearch.
|
||||
"""
|
||||
return {
|
||||
"id": size.id,
|
||||
"name": size.name,
|
||||
"code": size.code,
|
||||
"description": size.description,
|
||||
"created_at": size.created_at.isoformat() if size.created_at else None,
|
||||
"updated_at": size.updated_at.isoformat() if size.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
def sync_products(db: Session):
|
||||
"""
|
||||
Синхронизирует все продукты с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing products...")
|
||||
|
||||
# Получаем все продукты из базы данных
|
||||
products = db.query(Product).all()
|
||||
|
||||
# Форматируем продукты для Meilisearch
|
||||
products_data = []
|
||||
for product in products:
|
||||
variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
|
||||
images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
|
||||
product_data = format_product_for_meilisearch(product, variants, images)
|
||||
products_data.append(product_data)
|
||||
|
||||
# Синхронизируем продукты с Meilisearch
|
||||
success = meilisearch_service.sync_all_products(products_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(products_data)} products")
|
||||
else:
|
||||
logger.error("Failed to sync products")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def sync_categories(db: Session):
|
||||
"""
|
||||
Синхронизирует все категории с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing categories...")
|
||||
|
||||
# Получаем все категории из базы данных
|
||||
categories = db.query(Category).all()
|
||||
|
||||
# Форматируем категории для Meilisearch
|
||||
categories_data = [format_category_for_meilisearch(category) for category in categories]
|
||||
|
||||
# Синхронизируем категории с Meilisearch
|
||||
success = meilisearch_service.sync_all_categories(categories_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(categories_data)} categories")
|
||||
else:
|
||||
logger.error("Failed to sync categories")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def sync_collections(db: Session):
|
||||
"""
|
||||
Синхронизирует все коллекции с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing collections...")
|
||||
|
||||
# Получаем все коллекции из базы данных
|
||||
collections = db.query(Collection).all()
|
||||
|
||||
# Форматируем коллекции для Meilisearch
|
||||
collections_data = [format_collection_for_meilisearch(collection) for collection in collections]
|
||||
|
||||
# Синхронизируем коллекции с Meilisearch
|
||||
success = meilisearch_service.sync_all_collections(collections_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(collections_data)} collections")
|
||||
else:
|
||||
logger.error("Failed to sync collections")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def sync_sizes(db: Session):
|
||||
"""
|
||||
Синхронизирует все размеры с Meilisearch.
|
||||
"""
|
||||
logger.info("Syncing sizes...")
|
||||
|
||||
# Получаем все размеры из базы данных
|
||||
sizes = db.query(Size).all()
|
||||
|
||||
# Форматируем размеры для Meilisearch
|
||||
sizes_data = [format_size_for_meilisearch(size) for size in sizes]
|
||||
|
||||
# Синхронизируем размеры с Meilisearch
|
||||
success = meilisearch_service.sync_all_sizes(sizes_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully synced {len(sizes_data)} sizes")
|
||||
else:
|
||||
logger.error("Failed to sync sizes")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Основная функция для синхронизации всех данных с Meilisearch.
|
||||
"""
|
||||
logger.info("Starting Meilisearch synchronization...")
|
||||
|
||||
# Инициализируем индексы в Meilisearch
|
||||
meilisearch_service.initialize_indexes()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Синхронизируем все данные
|
||||
sync_categories(db)
|
||||
sync_collections(db)
|
||||
sync_sizes(db)
|
||||
sync_products(db)
|
||||
|
||||
logger.info("Meilisearch synchronization completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Meilisearch synchronization: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -11,12 +11,12 @@ from app.services.catalog_service import (
|
||||
upload_product_image, update_product_image, delete_product_image,
|
||||
create_collection, update_collection, delete_collection, get_collections,
|
||||
get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size,
|
||||
create_product_complete, update_product_complete
|
||||
create_product_complete, update_product_complete, format_product
|
||||
)
|
||||
|
||||
from app.services.order_service import (
|
||||
add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart,
|
||||
create_order, get_order, update_order, cancel_order,
|
||||
create_order, create_order_new, get_order, update_order, cancel_order,
|
||||
)
|
||||
|
||||
from app.services.review_service import (
|
||||
@ -30,5 +30,6 @@ from app.services.content_service import (
|
||||
|
||||
from app.services.delivery_service import get_cdek_service
|
||||
|
||||
|
||||
# Импорт репозиториев для прямого доступа
|
||||
from app.repositories import order_repo
|
||||
from app.repositories import order_repo
|
||||
@ -10,6 +10,13 @@ from botocore.exceptions import ClientError
|
||||
|
||||
from app.config import settings
|
||||
from app.repositories import catalog_repo, review_repo
|
||||
from app.services import meilisearch_service
|
||||
from app.scripts.sync_meilisearch import (
|
||||
format_product_for_meilisearch,
|
||||
format_category_for_meilisearch,
|
||||
format_collection_for_meilisearch,
|
||||
format_size_for_meilisearch
|
||||
)
|
||||
from app.schemas.catalog_schemas import (
|
||||
CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories,
|
||||
ProductCreate, ProductUpdate, Product, ProductWithDetails,
|
||||
@ -21,33 +28,76 @@ from app.schemas.catalog_schemas import (
|
||||
)
|
||||
|
||||
|
||||
def format_product(product):
|
||||
"""
|
||||
Форматирует продукт для отображения в API.
|
||||
Преобразует объект SQLAlchemy в словарь с нужными полями.
|
||||
"""
|
||||
from app.schemas.catalog_schemas import Product as ProductSchema
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
product_schema = ProductSchema.model_validate(product)
|
||||
|
||||
# Получаем основное изображение, если есть
|
||||
primary_image = None
|
||||
for image in product.images:
|
||||
if image.is_primary:
|
||||
primary_image = image.image_url
|
||||
break
|
||||
|
||||
# Если нет основного изображения, но есть другие изображения, берем первое
|
||||
if primary_image is None and product.images:
|
||||
primary_image = product.images[0].image_url
|
||||
|
||||
# Создаем словарь с данными продукта
|
||||
result = product_schema.model_dump()
|
||||
result["primary_image"] = primary_image
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Сервисы для коллекций
|
||||
def create_collection(db: Session, collection: CollectionCreate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema
|
||||
|
||||
|
||||
new_collection = catalog_repo.create_collection(db, collection)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
collection_schema = CollectionSchema.model_validate(new_collection)
|
||||
|
||||
# Индексируем коллекцию в Meilisearch
|
||||
collection_data = format_collection_for_meilisearch(new_collection)
|
||||
meilisearch_service.index_collection(collection_data)
|
||||
|
||||
return {"collection": collection_schema}
|
||||
|
||||
|
||||
def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema
|
||||
|
||||
|
||||
updated_collection = catalog_repo.update_collection(db, collection_id, collection)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
collection_schema = CollectionSchema.model_validate(updated_collection)
|
||||
|
||||
# Обновляем коллекцию в Meilisearch
|
||||
collection_data = format_collection_for_meilisearch(updated_collection)
|
||||
meilisearch_service.index_collection(collection_data)
|
||||
|
||||
return {"collection": collection_schema}
|
||||
|
||||
|
||||
def delete_collection(db: Session, collection_id: int) -> Dict[str, Any]:
|
||||
success = catalog_repo.delete_collection(db, collection_id)
|
||||
|
||||
# Удаляем коллекцию из Meilisearch
|
||||
if success:
|
||||
meilisearch_service.delete_collection(collection_id)
|
||||
|
||||
return {"success": success}
|
||||
|
||||
|
||||
def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema
|
||||
|
||||
|
||||
collections = catalog_repo.get_collections(db, skip, limit)
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
collections_schema = [CollectionSchema.model_validate(collection) for collection in collections]
|
||||
@ -57,24 +107,39 @@ def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, A
|
||||
# Сервисы каталога
|
||||
def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
|
||||
new_category = catalog_repo.create_category(db, category)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(new_category)
|
||||
|
||||
# Индексируем категорию в Meilisearch
|
||||
category_data = format_category_for_meilisearch(new_category)
|
||||
meilisearch_service.index_category(category_data)
|
||||
|
||||
return {"category": category_schema}
|
||||
|
||||
|
||||
def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
|
||||
updated_category = catalog_repo.update_category(db, category_id, category)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(updated_category)
|
||||
|
||||
# Обновляем категорию в Meilisearch
|
||||
category_data = format_category_for_meilisearch(updated_category)
|
||||
meilisearch_service.index_category(category_data)
|
||||
|
||||
return {"category": category_schema}
|
||||
|
||||
|
||||
def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
|
||||
success = catalog_repo.delete_category(db, category_id)
|
||||
|
||||
# Удаляем категорию из Meilisearch
|
||||
if success:
|
||||
meilisearch_service.delete_category(category_id)
|
||||
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@ -82,10 +147,10 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
from sqlalchemy import func
|
||||
from app.models.catalog_models import Product
|
||||
|
||||
|
||||
# Получаем все категории верхнего уровня
|
||||
root_categories = catalog_repo.get_categories(db, parent_id=None)
|
||||
|
||||
|
||||
result = []
|
||||
for category in root_categories:
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
@ -95,14 +160,14 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]:
|
||||
Product.category_id == category.id,
|
||||
Product.is_active == True
|
||||
).scalar() or 0
|
||||
|
||||
|
||||
# Рекурсивно получаем подкатегории
|
||||
category_dict = category_schema.model_dump()
|
||||
subcategories, subcategories_products_count = _get_subcategories(db, category.id)
|
||||
category_dict["subcategories"] = subcategories
|
||||
category_dict["products_count"] = products_count + subcategories_products_count
|
||||
result.append(category_dict)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -110,33 +175,33 @@ def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any]
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
from sqlalchemy import func
|
||||
from app.models.catalog_models import Product
|
||||
|
||||
|
||||
subcategories = catalog_repo.get_categories(db, parent_id=parent_id)
|
||||
|
||||
|
||||
result = []
|
||||
total_products_count = 0
|
||||
|
||||
|
||||
for category in subcategories:
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(category)
|
||||
|
||||
|
||||
# Получаем количество продуктов в категории
|
||||
products_count = db.query(func.count(Product.id)).filter(
|
||||
Product.category_id == category.id,
|
||||
Product.is_active == True
|
||||
).scalar() or 0
|
||||
|
||||
|
||||
category_dict = category_schema.model_dump()
|
||||
sub_subcategories, sub_products_count = _get_subcategories(db, category.id)
|
||||
|
||||
|
||||
total_products_in_category = products_count + sub_products_count
|
||||
total_products_count += total_products_in_category
|
||||
|
||||
|
||||
category_dict["subcategories"] = sub_subcategories
|
||||
category_dict["products_count"] = total_products_in_category
|
||||
|
||||
|
||||
result.append(category_dict)
|
||||
|
||||
|
||||
return result, total_products_count
|
||||
|
||||
|
||||
@ -150,7 +215,7 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что коллекция существует, если она указана
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
@ -159,13 +224,19 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Создаем продукт
|
||||
db_product = catalog_repo.create_product(db, product)
|
||||
|
||||
|
||||
# Индексируем продукт в Meilisearch
|
||||
variants = db_product.variants
|
||||
images = db_product.images
|
||||
product_data = format_product_for_meilisearch(db_product, variants, images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": Product.from_orm(db_product)
|
||||
"product": Product.model_validate(db_product)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
@ -189,7 +260,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется категория, проверяем, что она существует
|
||||
if product.category_id is not None:
|
||||
category = catalog_repo.get_category(db, product.category_id)
|
||||
@ -198,7 +269,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется коллекция, проверяем, что она существует
|
||||
if product.collection_id is not None:
|
||||
if product.collection_id:
|
||||
@ -208,13 +279,19 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем продукт
|
||||
updated_product = catalog_repo.update_product(db, product_id, product)
|
||||
|
||||
|
||||
# Обновляем продукт в Meilisearch
|
||||
variants = updated_product.variants
|
||||
images = updated_product.images
|
||||
product_data = format_product_for_meilisearch(updated_product, variants, images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": Product.from_orm(updated_product)
|
||||
"product": Product.model_validate(updated_product)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
@ -229,13 +306,57 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
|
||||
|
||||
|
||||
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
success = catalog_repo.delete_product(db, product_id)
|
||||
return {"success": success}
|
||||
"""Удалить продукт"""
|
||||
try:
|
||||
logging.warning(f"Удаление продукта с ID {product_id}")
|
||||
# Проверяем, что продукт существует
|
||||
db_product = catalog_repo.get_product(db, product_id)
|
||||
logging.warning(f"Продукт: {db_product}, {db_product.id if db_product else 'не найден'}")
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
# Удаляем продукт
|
||||
success = catalog_repo.delete_product(db, product_id)
|
||||
logging.warning(f"Удаление продукта с ID {product_id} успешно: {success}")
|
||||
# Удаляем продукт из Meilisearch
|
||||
if success:
|
||||
meilisearch_service.delete_product(product_id)
|
||||
|
||||
return {
|
||||
"success": success
|
||||
}
|
||||
except HTTPException as e:
|
||||
logging.error(f"HTTP ошибка при удалении продукта с ID {product_id}: {e.detail}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"Неожиданная ошибка при удалении продукта с ID {product_id}: {str(e)}")
|
||||
logging.error(traceback.format_exc())
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
"""Получить детальную информацию о продукте"""
|
||||
try:
|
||||
# Пробуем получить продукт из Meilisearch
|
||||
meilisearch_result = meilisearch_service.get_product(product_id)
|
||||
|
||||
if meilisearch_result["success"]:
|
||||
# Если продукт найден в Meilisearch, возвращаем его
|
||||
return {
|
||||
"success": True,
|
||||
"product": meilisearch_result["product"]
|
||||
}
|
||||
|
||||
# Если продукт не найден в Meilisearch, получаем его из базы данных
|
||||
# Получаем продукт
|
||||
product = catalog_repo.get_product(db, product_id)
|
||||
if not product:
|
||||
@ -243,21 +364,21 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Получаем варианты продукта
|
||||
variants = catalog_repo.get_product_variants(db, product_id)
|
||||
|
||||
|
||||
# Получаем изображения продукта
|
||||
images = catalog_repo.get_product_images(db, product_id)
|
||||
|
||||
|
||||
# Получаем категорию
|
||||
category = catalog_repo.get_category(db, product.category_id)
|
||||
|
||||
|
||||
# Получаем коллекцию, если она указана
|
||||
collection = None
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
|
||||
|
||||
# Создаем детальное представление продукта
|
||||
product_details = ProductWithDetails(
|
||||
id=product.id,
|
||||
@ -272,12 +393,16 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
collection_id=product.collection_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
category=Category.from_orm(category),
|
||||
collection=Collection.from_orm(collection) if collection else None,
|
||||
variants=[ProductVariant.from_orm(variant) for variant in variants],
|
||||
images=[ProductImage.from_orm(image) for image in images]
|
||||
category=Category.model_validate(category),
|
||||
collection=Collection.model_validate(collection) if collection else None,
|
||||
variants=[ProductVariant.model_validate(variant) for variant in variants],
|
||||
images=[ProductImage.model_validate(image) for image in images]
|
||||
)
|
||||
|
||||
|
||||
# Индексируем продукт в Meilisearch для будущих запросов
|
||||
product_data = format_product_for_meilisearch(product, variants, images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_details
|
||||
@ -304,7 +429,7 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что размер существует
|
||||
size = catalog_repo.get_size(db, variant.size_id)
|
||||
if not size:
|
||||
@ -312,10 +437,10 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Создаем вариант продукта
|
||||
db_variant = catalog_repo.create_product_variant(db, variant)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"variant": ProductVariant.from_orm(db_variant)
|
||||
@ -342,7 +467,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется продукт, проверяем, что он существует
|
||||
if variant.product_id is not None:
|
||||
product = catalog_repo.get_product(db, variant.product_id)
|
||||
@ -351,7 +476,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется размер, проверяем, что он существует
|
||||
if variant.size_id is not None:
|
||||
size = catalog_repo.get_size(db, variant.size_id)
|
||||
@ -360,10 +485,10 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем вариант продукта
|
||||
updated_variant = catalog_repo.update_product_variant(db, variant_id, variant)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"variant": ProductVariant.from_orm(updated_variant)
|
||||
@ -386,25 +511,25 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def upload_product_image(
|
||||
db: Session,
|
||||
product_id: int,
|
||||
file: UploadFile,
|
||||
is_primary: bool = False,
|
||||
db: Session,
|
||||
product_id: int,
|
||||
file: UploadFile,
|
||||
is_primary: bool = False,
|
||||
alt_text: str = ""
|
||||
) -> dict:
|
||||
"""
|
||||
Загружает изображение для продукта в MinIO и создает запись в базе данных.
|
||||
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
product_id: ID продукта
|
||||
file: Загружаемый файл
|
||||
is_primary: Является ли изображение основным
|
||||
alt_text: Альтернативный текст для изображения
|
||||
|
||||
|
||||
Returns:
|
||||
dict: Словарь с данными созданного изображения (image_url содержит ключ объекта)
|
||||
|
||||
|
||||
Raises:
|
||||
HTTPException: В случае ошибки при загрузке или сохранении изображения
|
||||
"""
|
||||
@ -412,36 +537,36 @@ def upload_product_image(
|
||||
secure_filename = ""
|
||||
try:
|
||||
logging.info(f"Попытка загрузки изображения для продукта с id={product_id} в MinIO")
|
||||
|
||||
|
||||
if file is None:
|
||||
error_msg = "Файл не предоставлен"
|
||||
logging.error(error_msg)
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
if not file.filename:
|
||||
error_msg = "У файла отсутствует имя"
|
||||
logging.error(error_msg)
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
logging.info(f"Получен файл: {file.filename}, content_type: {file.content_type}")
|
||||
|
||||
|
||||
from app.models.catalog_models import Product as ProductModel, ProductImage as ProductImageModel
|
||||
|
||||
|
||||
product = db.query(ProductModel).filter(ProductModel.id == product_id).first()
|
||||
if not product:
|
||||
error_msg = f"Продукт с ID {product_id} не найден"
|
||||
logging.error(error_msg)
|
||||
raise HTTPException(status_code=404, detail=error_msg)
|
||||
|
||||
|
||||
file_ext = os.path.splitext(file.filename)[1].lower() if file.filename else ""
|
||||
if file_ext.lstrip('.') not in settings.ALLOWED_UPLOAD_EXTENSIONS:
|
||||
error_msg = f"Расширение файла {file_ext} не разрешено."
|
||||
logging.error(error_msg)
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
|
||||
secure_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
logging.info(f"Генерация ключа объекта MinIO: {secure_filename}")
|
||||
|
||||
|
||||
try:
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
@ -457,7 +582,7 @@ def upload_product_image(
|
||||
logging.error(error_msg)
|
||||
logging.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail="Ошибка конфигурации хранилища")
|
||||
|
||||
|
||||
try:
|
||||
s3_client.upload_fileobj(
|
||||
file.file,
|
||||
@ -476,17 +601,17 @@ def upload_product_image(
|
||||
logging.error(error_msg)
|
||||
logging.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail="Ошибка при сохранении файла в хранилище")
|
||||
|
||||
|
||||
image_object_key = secure_filename
|
||||
logging.info(f"Ключ объекта для сохранения в БД: {image_object_key}")
|
||||
|
||||
|
||||
if is_primary:
|
||||
logging.info("Обновление флагов для других изображений (is_primary=False)")
|
||||
db.query(ProductImageModel).filter(
|
||||
ProductImageModel.product_id == product_id,
|
||||
ProductImageModel.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
|
||||
|
||||
logging.info("Создание записи изображения в базе данных")
|
||||
new_image = ProductImageModel(
|
||||
product_id=product_id,
|
||||
@ -494,7 +619,7 @@ def upload_product_image(
|
||||
alt_text=alt_text,
|
||||
is_primary=is_primary
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(new_image)
|
||||
db.commit()
|
||||
@ -505,20 +630,20 @@ def upload_product_image(
|
||||
error_msg = f"Ошибка при сохранении записи в базу данных: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
|
||||
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за ошибки БД.")
|
||||
try:
|
||||
if s3_client and secure_filename:
|
||||
s3_client.delete_object(
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Key=secure_filename
|
||||
)
|
||||
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
|
||||
except Exception as delete_err:
|
||||
logging.error(f"Не удалось удалить объект '{secure_filename}' из MinIO после ошибки БД: {str(delete_err)}")
|
||||
|
||||
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения данных изображения")
|
||||
|
||||
|
||||
result = {
|
||||
"id": new_image.id,
|
||||
"product_id": new_image.product_id,
|
||||
@ -528,10 +653,10 @@ def upload_product_image(
|
||||
"created_at": new_image.created_at.isoformat() if new_image.created_at else None,
|
||||
"updated_at": new_image.updated_at.isoformat() if new_image.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
logging.info(f"Возвращается результат: {result}")
|
||||
return result
|
||||
|
||||
|
||||
except HTTPException as http_error:
|
||||
raise http_error
|
||||
except Exception as e:
|
||||
@ -542,7 +667,7 @@ def upload_product_image(
|
||||
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за неожиданной ошибки.")
|
||||
try:
|
||||
s3_client.delete_object(
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Key=secure_filename
|
||||
)
|
||||
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
|
||||
@ -554,7 +679,7 @@ def upload_product_image(
|
||||
def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
|
||||
from app.models.catalog_models import ProductImage as ProductImageModel
|
||||
|
||||
|
||||
try:
|
||||
# Получаем существующее изображение
|
||||
db_image = catalog_repo.get_product_image(db, image_id)
|
||||
@ -563,7 +688,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Изображение продукта не найдено"
|
||||
)
|
||||
|
||||
|
||||
# Если изображение отмечается как основное, сбрасываем флаг у других изображений
|
||||
if image.is_primary and not db_image.is_primary:
|
||||
db.query(ProductImageModel).filter(
|
||||
@ -571,16 +696,16 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
|
||||
ProductImageModel.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
db.flush()
|
||||
|
||||
|
||||
# Обновляем поля
|
||||
if image.alt_text is not None:
|
||||
db_image.alt_text = image.alt_text
|
||||
if image.is_primary is not None:
|
||||
db_image.is_primary = image.is_primary
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_image)
|
||||
|
||||
|
||||
# Создаем словарь для ответа вместо использования from_orm
|
||||
image_dict = {
|
||||
"id": db_image.id,
|
||||
@ -591,7 +716,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
|
||||
"created_at": db_image.created_at,
|
||||
"updated_at": db_image.updated_at
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image": image_dict
|
||||
@ -612,10 +737,10 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
|
||||
|
||||
def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
|
||||
from app.models.catalog_models import ProductImage as ProductImageModel
|
||||
|
||||
|
||||
image_key_to_delete = None # Сохраняем ключ для удаления из MinIO
|
||||
s3_client = None
|
||||
|
||||
|
||||
try:
|
||||
# Получаем информацию об изображении перед удалением
|
||||
db_image = catalog_repo.get_product_image(db, image_id)
|
||||
@ -624,13 +749,13 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Изображение не найдено"
|
||||
)
|
||||
|
||||
|
||||
image_key_to_delete = db_image.image_url # Получаем ключ объекта MinIO из БД
|
||||
logging.info(f"Получен ключ объекта для удаления из MinIO: {image_key_to_delete}")
|
||||
|
||||
# Удаляем запись из БД
|
||||
success = catalog_repo.delete_product_image(db, image_id)
|
||||
|
||||
|
||||
# Если запись из БД удалена успешно, удаляем объект из MinIO
|
||||
if success and image_key_to_delete:
|
||||
logging.info(f"Запись из БД удалена, попытка удаления объекта '{image_key_to_delete}' из MinIO.")
|
||||
@ -644,9 +769,9 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
|
||||
use_ssl=settings.MINIO_USE_SSL,
|
||||
config=boto3.session.Config(signature_version='s3v4')
|
||||
)
|
||||
|
||||
|
||||
s3_client.delete_object(
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Bucket=settings.MINIO_BUCKET_NAME,
|
||||
Key=image_key_to_delete
|
||||
)
|
||||
logging.info(f"Объект '{image_key_to_delete}' успешно удален из MinIO бакета '{settings.MINIO_BUCKET_NAME}'.")
|
||||
@ -706,15 +831,33 @@ def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]:
|
||||
|
||||
|
||||
def create_size(db: Session, size: SizeCreate) -> Size:
|
||||
return Size.from_orm(catalog_repo.create_size(db, size))
|
||||
new_size = catalog_repo.create_size(db, size)
|
||||
|
||||
# Индексируем размер в Meilisearch
|
||||
size_data = format_size_for_meilisearch(new_size)
|
||||
meilisearch_service.index_size(size_data)
|
||||
|
||||
return Size.model_validate(new_size)
|
||||
|
||||
|
||||
def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
return Size.from_orm(catalog_repo.update_size(db, size_id, size))
|
||||
updated_size = catalog_repo.update_size(db, size_id, size)
|
||||
|
||||
# Обновляем размер в Meilisearch
|
||||
size_data = format_size_for_meilisearch(updated_size)
|
||||
meilisearch_service.index_size(size_data)
|
||||
|
||||
return Size.model_validate(updated_size)
|
||||
|
||||
|
||||
def delete_size(db: Session, size_id: int) -> bool:
|
||||
return catalog_repo.delete_size(db, size_id)
|
||||
success = catalog_repo.delete_size(db, size_id)
|
||||
|
||||
# Удаляем размер из Meilisearch
|
||||
if success:
|
||||
meilisearch_service.delete_size(size_id)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def create_product_complete(db: Session, product_data: ProductCreateComplete) -> Dict[str, Any]:
|
||||
@ -724,7 +867,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
try:
|
||||
# Откатываем любую существующую транзакцию и начинаем новую
|
||||
db.rollback()
|
||||
|
||||
|
||||
# Проверяем наличие категории
|
||||
category = catalog_repo.get_category(db, product_data.category_id)
|
||||
if not category:
|
||||
@ -732,7 +875,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product_data.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем наличие коллекции, если она указана
|
||||
if product_data.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product_data.collection_id)
|
||||
@ -741,7 +884,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product_data.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# 1. Создаем базовую информацию о продукте используя репозиторий
|
||||
product_create = ProductCreate(
|
||||
name=product_data.name,
|
||||
@ -754,10 +897,10 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
category_id=product_data.category_id,
|
||||
collection_id=product_data.collection_id
|
||||
)
|
||||
|
||||
|
||||
# Создаем продукт через репозиторий
|
||||
db_product = catalog_repo.create_product(db, product_create)
|
||||
|
||||
|
||||
# 2. Создаем варианты продукта
|
||||
variants = []
|
||||
if product_data.variants:
|
||||
@ -769,7 +912,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant_data.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Создаем вариант продукта через репозиторий
|
||||
variant_create = ProductVariantCreate(
|
||||
product_id=db_product.id,
|
||||
@ -780,13 +923,13 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
)
|
||||
db_variant = catalog_repo.create_product_variant(db, variant_create)
|
||||
variants.append(db_variant)
|
||||
|
||||
|
||||
# 3. Создаем изображения продукта
|
||||
images = []
|
||||
if product_data.images:
|
||||
# Отслеживаем, было ли уже отмечено изображение как основное
|
||||
had_primary = False
|
||||
|
||||
|
||||
for image_data in product_data.images:
|
||||
# Определяем, должно ли изображение быть основным
|
||||
is_primary = image_data.is_primary
|
||||
@ -799,7 +942,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
# Если это первое изображение и нет основного, делаем его основным
|
||||
is_primary = True
|
||||
had_primary = True
|
||||
|
||||
|
||||
# Создаем запись об изображении через репозиторий
|
||||
image_create = ProductImageCreate(
|
||||
product_id=db_product.id,
|
||||
@ -809,7 +952,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
)
|
||||
db_image = catalog_repo.create_product_image(db, image_create)
|
||||
images.append(db_image)
|
||||
|
||||
|
||||
# Создаем словарь для ответа с прямым извлечением данных из ORM-объектов
|
||||
product_dict = {
|
||||
"id": db_product.id,
|
||||
@ -825,7 +968,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"created_at": db_product.created_at,
|
||||
"updated_at": db_product.updated_at
|
||||
}
|
||||
|
||||
|
||||
# Создаем списки вариантов и изображений
|
||||
variants_list = []
|
||||
for variant in variants:
|
||||
@ -839,7 +982,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"created_at": variant.created_at,
|
||||
"updated_at": variant.updated_at
|
||||
})
|
||||
|
||||
|
||||
images_list = []
|
||||
for image in images:
|
||||
images_list.append({
|
||||
@ -851,7 +994,11 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"created_at": image.created_at,
|
||||
"updated_at": image.updated_at
|
||||
})
|
||||
|
||||
|
||||
# Индексируем продукт в Meilisearch
|
||||
product_data = format_product_for_meilisearch(db_product, variants, images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"id": db_product.id,
|
||||
@ -859,7 +1006,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"variants": variants_list,
|
||||
"images": images_list
|
||||
}
|
||||
|
||||
|
||||
except HTTPException as e:
|
||||
db.rollback()
|
||||
return {
|
||||
@ -872,7 +1019,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
|
||||
def update_product_complete(db: Session, product_id: int, product_data: ProductUpdateComplete) -> Dict[str, Any]:
|
||||
"""
|
||||
@ -881,7 +1028,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
try:
|
||||
# Откатываем любую существующую транзакцию и начинаем новую
|
||||
db.rollback()
|
||||
|
||||
|
||||
# Проверяем, что продукт существует
|
||||
db_product = catalog_repo.get_product(db, product_id)
|
||||
if not db_product:
|
||||
@ -889,7 +1036,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется категория, проверяем, что она существует
|
||||
if product_data.category_id is not None:
|
||||
category = catalog_repo.get_category(db, product_data.category_id)
|
||||
@ -898,7 +1045,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product_data.category_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Если меняется коллекция, проверяем, что она существует
|
||||
if product_data.collection_id is not None:
|
||||
if product_data.collection_id > 0:
|
||||
@ -908,7 +1055,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product_data.collection_id} не найдена"
|
||||
)
|
||||
|
||||
|
||||
# 1. Обновляем базовую информацию о продукте
|
||||
product_update = ProductUpdate(
|
||||
name=product_data.name,
|
||||
@ -922,11 +1069,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
collection_id=product_data.collection_id
|
||||
)
|
||||
db_product = catalog_repo.update_product(db, product_id, product_update)
|
||||
|
||||
|
||||
# 2. Обрабатываем варианты продукта
|
||||
variants_updated = []
|
||||
variants_created = []
|
||||
|
||||
|
||||
if product_data.variants:
|
||||
for variant_data in product_data.variants:
|
||||
# Проверяем наличие размера
|
||||
@ -937,7 +1084,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant_data.size_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Если есть ID, обновляем существующий вариант
|
||||
if variant_data.id:
|
||||
variant_update = ProductVariantUpdate(
|
||||
@ -960,7 +1107,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
)
|
||||
db_variant = catalog_repo.create_product_variant(db, variant_create)
|
||||
variants_created.append(db_variant)
|
||||
|
||||
|
||||
# 3. Удаляем указанные варианты
|
||||
variants_removed = []
|
||||
if product_data.variants_to_remove:
|
||||
@ -971,11 +1118,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
success = catalog_repo.delete_product_variant(db, variant_id)
|
||||
if success:
|
||||
variants_removed.append(variant_id)
|
||||
|
||||
|
||||
# 4. Обрабатываем изображения продукта
|
||||
images_updated = []
|
||||
images_created = []
|
||||
|
||||
|
||||
if product_data.images:
|
||||
for image_data in product_data.images:
|
||||
# Если есть ID, обновляем существующее изображение
|
||||
@ -997,7 +1144,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
)
|
||||
db_image = catalog_repo.create_product_image(db, image_create)
|
||||
images_created.append(db_image)
|
||||
|
||||
|
||||
# 5. Удаляем указанные изображения
|
||||
images_removed = []
|
||||
if product_data.images_to_remove:
|
||||
@ -1008,28 +1155,35 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
success = catalog_repo.delete_product_image(db, image_id)
|
||||
if success:
|
||||
images_removed.append(image_id)
|
||||
|
||||
|
||||
# Коммитим транзакцию
|
||||
db.commit()
|
||||
|
||||
|
||||
# Обновляем продукт в Meilisearch
|
||||
# Получаем все варианты и изображения продукта после обновления
|
||||
updated_variants = db_product.variants
|
||||
updated_images = db_product.images
|
||||
product_data = format_product_for_meilisearch(db_product, updated_variants, updated_images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
# Собираем полный ответ
|
||||
product_schema = Product.from_orm(db_product)
|
||||
|
||||
product_schema = Product.model_validate(db_product)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_schema,
|
||||
"variants": {
|
||||
"updated": [ProductVariant.from_orm(variant) for variant in variants_updated],
|
||||
"created": [ProductVariant.from_orm(variant) for variant in variants_created],
|
||||
"updated": [ProductVariant.model_validate(variant) for variant in variants_updated],
|
||||
"created": [ProductVariant.model_validate(variant) for variant in variants_created],
|
||||
"removed": variants_removed
|
||||
},
|
||||
"images": {
|
||||
"updated": [ProductImage.from_orm(image) for image in images_updated],
|
||||
"created": [ProductImage.from_orm(image) for image in images_created],
|
||||
"updated": [ProductImage.model_validate(image) for image in images_updated],
|
||||
"created": [ProductImage.model_validate(image) for image in images_created],
|
||||
"removed": images_removed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
except HTTPException as e:
|
||||
db.rollback()
|
||||
return {
|
||||
@ -1041,4 +1195,4 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
}
|
||||
649
backend/app/services/meilisearch_service.py
Normal file
@ -0,0 +1,649 @@
|
||||
from meilisearch import Client
|
||||
from typing import Dict, List, Any, Optional, Union
|
||||
import logging
|
||||
from app.config import settings
|
||||
|
||||
# Инициализация клиента Meilisearch
|
||||
client = Client(settings.MEILISEARCH_URL, settings.MEILISEARCH_KEY)
|
||||
|
||||
# Определение индексов
|
||||
PRODUCT_INDEX = "products"
|
||||
CATEGORY_INDEX = "categories"
|
||||
COLLECTION_INDEX = "collections"
|
||||
SIZE_INDEX = "sizes"
|
||||
|
||||
# Настройка логгера
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_indexes():
|
||||
"""
|
||||
Инициализирует индексы в Meilisearch, если они не существуют.
|
||||
Настраивает фильтруемые и сортируемые атрибуты.
|
||||
"""
|
||||
try:
|
||||
# Создаем индекс продуктов, если он не существует
|
||||
if PRODUCT_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
|
||||
client.create_index(PRODUCT_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для продуктов
|
||||
client.index(PRODUCT_INDEX).update_filterable_attributes([
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
|
||||
])
|
||||
|
||||
# Настраиваем сортируемые атрибуты для продуктов
|
||||
client.index(PRODUCT_INDEX).update_sortable_attributes([
|
||||
"price", "discount_price", "created_at", "name"
|
||||
])
|
||||
|
||||
# Настраиваем поисковые атрибуты для продуктов
|
||||
client.index(PRODUCT_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Создаем индекс категорий, если он не существует
|
||||
if CATEGORY_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
|
||||
client.create_index(CATEGORY_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для категорий
|
||||
client.index(CATEGORY_INDEX).update_filterable_attributes([
|
||||
"parent_id", "is_active", "id", "slug"
|
||||
])
|
||||
|
||||
# Настраиваем поисковые атрибуты для категорий
|
||||
client.index(CATEGORY_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Создаем индекс коллекций, если он не существует
|
||||
if COLLECTION_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
|
||||
client.create_index(COLLECTION_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для коллекций
|
||||
client.index(COLLECTION_INDEX).update_filterable_attributes([
|
||||
"is_active"
|
||||
])
|
||||
|
||||
# Настраиваем поисковые атрибуты для коллекций
|
||||
client.index(COLLECTION_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Создаем индекс размеров, если он не существует
|
||||
if SIZE_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
|
||||
client.create_index(SIZE_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем поисковые атрибуты для размеров
|
||||
client.index(SIZE_INDEX).update_searchable_attributes([
|
||||
"name", "code", "description"
|
||||
])
|
||||
|
||||
logger.info("Meilisearch indexes initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Meilisearch indexes: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def index_product(product_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Индексирует продукт в Meilisearch.
|
||||
|
||||
Args:
|
||||
product_data: Данные продукта для индексации
|
||||
|
||||
Returns:
|
||||
bool: True, если индексация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(PRODUCT_INDEX).add_documents([product_data])
|
||||
logger.info(f"Product {product_data.get('id')} indexed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing product {product_data.get('id')}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def index_category(category_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Индексирует категорию в Meilisearch.
|
||||
|
||||
Args:
|
||||
category_data: Данные категории для индексации
|
||||
|
||||
Returns:
|
||||
bool: True, если индексация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(CATEGORY_INDEX).add_documents([category_data])
|
||||
logger.info(f"Category {category_data.get('id')} indexed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing category {category_data.get('id')}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def index_collection(collection_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Индексирует коллекцию в Meilisearch.
|
||||
|
||||
Args:
|
||||
collection_data: Данные коллекции для индексации
|
||||
|
||||
Returns:
|
||||
bool: True, если индексация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(COLLECTION_INDEX).add_documents([collection_data])
|
||||
logger.info(f"Collection {collection_data.get('id')} indexed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing collection {collection_data.get('id')}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def index_size(size_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Индексирует размер в Meilisearch.
|
||||
|
||||
Args:
|
||||
size_data: Данные размера для индексации
|
||||
|
||||
Returns:
|
||||
bool: True, если индексация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(SIZE_INDEX).add_documents([size_data])
|
||||
logger.info(f"Size {size_data.get('id')} indexed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing size {size_data.get('id')}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_product(product_id: int) -> bool:
|
||||
"""
|
||||
Удаляет продукт из индекса Meilisearch.
|
||||
|
||||
Args:
|
||||
product_id: ID продукта для удаления
|
||||
|
||||
Returns:
|
||||
bool: True, если удаление прошло успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(PRODUCT_INDEX).delete_document(product_id)
|
||||
logger.info(f"Product {product_id} deleted from index successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting product {product_id} from index: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_category(category_id: int) -> bool:
|
||||
"""
|
||||
Удаляет категорию из индекса Meilisearch.
|
||||
|
||||
Args:
|
||||
category_id: ID категории для удаления
|
||||
|
||||
Returns:
|
||||
bool: True, если удаление прошло успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(CATEGORY_INDEX).delete_document(category_id)
|
||||
logger.info(f"Category {category_id} deleted from index successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting category {category_id} from index: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_collection(collection_id: int) -> bool:
|
||||
"""
|
||||
Удаляет коллекцию из индекса Meilisearch.
|
||||
|
||||
Args:
|
||||
collection_id: ID коллекции для удаления
|
||||
|
||||
Returns:
|
||||
bool: True, если удаление прошло успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(COLLECTION_INDEX).delete_document(collection_id)
|
||||
logger.info(f"Collection {collection_id} deleted from index successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting collection {collection_id} from index: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_size(size_id: int) -> bool:
|
||||
"""
|
||||
Удаляет размер из индекса Meilisearch.
|
||||
|
||||
Args:
|
||||
size_id: ID размера для удаления
|
||||
|
||||
Returns:
|
||||
bool: True, если удаление прошло успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(SIZE_INDEX).delete_document(size_id)
|
||||
logger.info(f"Size {size_id} deleted from index successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting size {size_id} from index: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def search_products(
|
||||
query: str = "",
|
||||
filters: Optional[str] = None,
|
||||
sort: Optional[List[str]] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Поиск продуктов в Meilisearch.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
filters: Фильтры в формате Meilisearch (например, "category_id = 1 AND price < 1000")
|
||||
sort: Список полей для сортировки (например, ["price:asc"])
|
||||
limit: Максимальное количество результатов
|
||||
offset: Смещение для пагинации
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты поиска
|
||||
"""
|
||||
try:
|
||||
search_params = {
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if filters:
|
||||
search_params["filter"] = filters
|
||||
|
||||
if sort:
|
||||
search_params["sort"] = sort
|
||||
|
||||
results = client.index(PRODUCT_INDEX).search(query, search_params)
|
||||
return {
|
||||
"success": True,
|
||||
"products": results["hits"],
|
||||
"total": results["estimatedTotalHits"],
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching products: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"products": [],
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
def search_categories(
|
||||
query: str = "",
|
||||
filters: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Поиск категорий в Meilisearch.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
filters: Фильтры в формате Meilisearch (например, "parent_id = 1")
|
||||
limit: Максимальное количество результатов
|
||||
offset: Смещение для пагинации
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты поиска
|
||||
"""
|
||||
try:
|
||||
search_params = {
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if filters:
|
||||
search_params["filter"] = filters
|
||||
|
||||
results = client.index(CATEGORY_INDEX).search(query, search_params)
|
||||
return {
|
||||
"success": True,
|
||||
"categories": results["hits"],
|
||||
"total": results["estimatedTotalHits"]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching categories: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"categories": [],
|
||||
"total": 0
|
||||
}
|
||||
|
||||
|
||||
def search_collections(
|
||||
query: str = "",
|
||||
filters: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Поиск коллекций в Meilisearch.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
filters: Фильтры в формате Meilisearch (например, "is_active = true")
|
||||
limit: Максимальное количество результатов
|
||||
offset: Смещение для пагинации
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты поиска
|
||||
"""
|
||||
try:
|
||||
search_params = {
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if filters:
|
||||
search_params["filter"] = filters
|
||||
|
||||
results = client.index(COLLECTION_INDEX).search(query, search_params)
|
||||
return {
|
||||
"success": True,
|
||||
"collections": results["hits"],
|
||||
"total": results["estimatedTotalHits"]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching collections: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"collections": [],
|
||||
"total": 0
|
||||
}
|
||||
|
||||
|
||||
def search_sizes(
|
||||
query: str = "",
|
||||
filters: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Поиск размеров в Meilisearch.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
filters: Фильтры в формате Meilisearch
|
||||
limit: Максимальное количество результатов
|
||||
offset: Смещение для пагинации
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты поиска
|
||||
"""
|
||||
try:
|
||||
search_params = {
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if filters:
|
||||
search_params["filter"] = filters
|
||||
|
||||
results = client.index(SIZE_INDEX).search(query, search_params)
|
||||
return {
|
||||
"success": True,
|
||||
"sizes": results["hits"],
|
||||
"total": results["estimatedTotalHits"]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching sizes: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"sizes": [],
|
||||
"total": 0
|
||||
}
|
||||
|
||||
|
||||
def get_product(product_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает продукт из Meilisearch по ID.
|
||||
|
||||
Args:
|
||||
product_id: ID продукта
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные продукта или ошибка
|
||||
"""
|
||||
try:
|
||||
product = client.index(PRODUCT_INDEX).get_document(product_id)
|
||||
# Преобразуем объект Document в словарь
|
||||
product_dict = dict(product)
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_dict
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting product {product_id}: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"product": None
|
||||
}
|
||||
|
||||
|
||||
def get_product_by_slug(slug: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает продукт из Meilisearch по slug.
|
||||
|
||||
Args:
|
||||
slug: Slug продукта
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные продукта или ошибка
|
||||
"""
|
||||
try:
|
||||
# Вместо фильтрации по slug, используем поиск по slug
|
||||
results = client.index(PRODUCT_INDEX).search(slug, {
|
||||
"attributesToSearchOn": ["slug"],
|
||||
"limit": 1
|
||||
})
|
||||
|
||||
if results["hits"]:
|
||||
# Преобразуем объект Document в словарь
|
||||
product_dict = dict(results["hits"][0])
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_dict
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Product with slug '{slug}' not found",
|
||||
"product": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting product by slug {slug}: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"product": None
|
||||
}
|
||||
|
||||
|
||||
def get_category(category_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает категорию из Meilisearch по ID.
|
||||
|
||||
Args:
|
||||
category_id: ID категории
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные категории или ошибка
|
||||
"""
|
||||
try:
|
||||
category = client.index(CATEGORY_INDEX).get_document(category_id)
|
||||
# Преобразуем объект Document в словарь
|
||||
category_dict = dict(category)
|
||||
return {
|
||||
"success": True,
|
||||
"category": category_dict
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting category {category_id}: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"category": None
|
||||
}
|
||||
|
||||
|
||||
def get_collection(collection_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает коллекцию из Meilisearch по ID.
|
||||
|
||||
Args:
|
||||
collection_id: ID коллекции
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные коллекции или ошибка
|
||||
"""
|
||||
try:
|
||||
collection = client.index(COLLECTION_INDEX).get_document(collection_id)
|
||||
# Преобразуем объект Document в словарь
|
||||
collection_dict = dict(collection)
|
||||
return {
|
||||
"success": True,
|
||||
"collection": collection_dict
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting collection {collection_id}: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"collection": None
|
||||
}
|
||||
|
||||
|
||||
def get_size(size_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает размер из Meilisearch по ID.
|
||||
|
||||
Args:
|
||||
size_id: ID размера
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Данные размера или ошибка
|
||||
"""
|
||||
try:
|
||||
size = client.index(SIZE_INDEX).get_document(size_id)
|
||||
# Преобразуем объект Document в словарь
|
||||
size_dict = dict(size)
|
||||
return {
|
||||
"success": True,
|
||||
"size": size_dict
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting size {size_id}: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"size": None
|
||||
}
|
||||
|
||||
|
||||
def sync_all_products(products_data: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Синхронизирует все продукты с Meilisearch.
|
||||
|
||||
Args:
|
||||
products_data: Список данных продуктов для синхронизации
|
||||
|
||||
Returns:
|
||||
bool: True, если синхронизация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(PRODUCT_INDEX).delete_all_documents()
|
||||
if products_data:
|
||||
client.index(PRODUCT_INDEX).add_documents(products_data)
|
||||
logger.info(f"All products synced successfully ({len(products_data)} products)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing all products: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_all_categories(categories_data: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Синхронизирует все категории с Meilisearch.
|
||||
|
||||
Args:
|
||||
categories_data: Список данных категорий для синхронизации
|
||||
|
||||
Returns:
|
||||
bool: True, если синхронизация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(CATEGORY_INDEX).delete_all_documents()
|
||||
if categories_data:
|
||||
client.index(CATEGORY_INDEX).add_documents(categories_data)
|
||||
logger.info(f"All categories synced successfully ({len(categories_data)} categories)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing all categories: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_all_collections(collections_data: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Синхронизирует все коллекции с Meilisearch.
|
||||
|
||||
Args:
|
||||
collections_data: Список данных коллекций для синхронизации
|
||||
|
||||
Returns:
|
||||
bool: True, если синхронизация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(COLLECTION_INDEX).delete_all_documents()
|
||||
if collections_data:
|
||||
client.index(COLLECTION_INDEX).add_documents(collections_data)
|
||||
logger.info(f"All collections synced successfully ({len(collections_data)} collections)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing all collections: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_all_sizes(sizes_data: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Синхронизирует все размеры с Meilisearch.
|
||||
|
||||
Args:
|
||||
sizes_data: Список данных размеров для синхронизации
|
||||
|
||||
Returns:
|
||||
bool: True, если синхронизация прошла успешно, иначе False
|
||||
"""
|
||||
try:
|
||||
client.index(SIZE_INDEX).delete_all_documents()
|
||||
if sizes_data:
|
||||
client.index(SIZE_INDEX).add_documents(sizes_data)
|
||||
logger.info(f"All sizes synced successfully ({len(sizes_data)} sizes)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing all sizes: {str(e)}")
|
||||
return False
|
||||
@ -1,9 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.repositories import order_repo, content_repo
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order, OrderCreateNew
|
||||
from app.schemas.content_schemas import AnalyticsLogCreate
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
@ -14,7 +14,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
|
||||
Добавляет товар в корзину пользователя.
|
||||
"""
|
||||
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
|
||||
|
||||
|
||||
# Логируем событие добавления в корзину
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -25,7 +25,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар успешно добавлен в корзину",
|
||||
@ -43,7 +43,7 @@ def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: Ca
|
||||
Обновляет количество товара в корзине пользователя.
|
||||
"""
|
||||
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар в корзине успешно обновлен",
|
||||
@ -61,7 +61,7 @@ def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str,
|
||||
Удаляет товар из корзины пользователя.
|
||||
"""
|
||||
success = order_repo.delete_cart_item(db, cart_item_id, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Товар успешно удален из корзины" if success else "Не удалось удалить товар из корзины"
|
||||
@ -73,7 +73,7 @@ def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
Очищает корзину пользователя.
|
||||
"""
|
||||
success = order_repo.clear_cart(db, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Корзина успешно очищена" if success else "Не удалось очистить корзину"
|
||||
@ -86,10 +86,10 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
# Получаем элементы корзины с деталями продуктов
|
||||
cart_items = order_repo.get_cart_with_product_details(db, user_id)
|
||||
|
||||
|
||||
# Рассчитываем общую сумму корзины
|
||||
total_amount = sum(item["total_price"] for item in cart_items)
|
||||
|
||||
|
||||
return {
|
||||
"items": cart_items,
|
||||
"total_amount": total_amount,
|
||||
@ -98,13 +98,13 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
# Сервисы заказов
|
||||
def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
|
||||
def create_order(db: Session, user_id: Optional[int], order: OrderCreate) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает новый заказ на основе корзины или переданных элементов.
|
||||
"""
|
||||
try:
|
||||
new_order = order_repo.create_order(db, order, user_id)
|
||||
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -115,10 +115,10 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно создан",
|
||||
@ -131,26 +131,103 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
|
||||
)
|
||||
|
||||
|
||||
def create_order_new(db: Session, user_id: Optional[int], order: OrderCreateNew) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает новый заказ на основе новой структуры данных.
|
||||
Если пользователь не авторизован (user_id=None), создает нового пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user_id: ID пользователя (None, если пользователь не авторизован)
|
||||
order: Данные для создания заказа
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о созданном заказе
|
||||
|
||||
Raises:
|
||||
HTTPException: Если произошла ошибка при создании заказа
|
||||
"""
|
||||
try:
|
||||
|
||||
print(f"Метод оплаты: {order.payment_method} {type(order.payment_method)} {order.payment_method.strip().lower()}")
|
||||
# Проверяем метод оплаты
|
||||
if order.payment_method.strip().lower() not in ["sbp", "card"]:
|
||||
# Если метод оплаты не поддерживается, используем значение по умолчанию
|
||||
order.payment_method = "card"
|
||||
print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: card")
|
||||
|
||||
# Создаем заказ с новой структурой данных
|
||||
new_order = order_repo.create_order_new(db, order, user_id)
|
||||
|
||||
# Получаем сообщение о создании пользователя, если оно есть
|
||||
user_created_message = getattr(new_order, "user_created_message", None)
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=new_order.user_id, # Используем ID пользователя из заказа
|
||||
event_type="order_created",
|
||||
additional_data={
|
||||
"order_id": new_order.id,
|
||||
"total_amount": new_order.total_amount,
|
||||
"delivery_method": new_order.delivery_method
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
# Формируем ответ для фронтенда
|
||||
response = {
|
||||
"success": True,
|
||||
"message": "Заказ успешно создан",
|
||||
"order": order_details,
|
||||
"order_id": new_order.id # Добавляем ID заказа для удобства
|
||||
}
|
||||
|
||||
# Если был создан новый пользователь, добавляем сообщение об этом
|
||||
if user_created_message:
|
||||
response["user_message"] = user_created_message
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
# Логируем ошибку для отладки
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
|
||||
# Проверяем, содержит ли ошибка информацию о проблеме с методом оплаты
|
||||
error_message = str(e)
|
||||
if "invalid input value for enum paymentmethod" in error_message.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный метод оплаты. Допустимые значения: 'sbp', 'card'"
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
"""
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
if not order_details:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and order_details["user_id"] != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для просмотра этого заказа"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order": order_details
|
||||
@ -168,27 +245,27 @@ def update_order(db: Session, user_id: int, order_id: int, order_update: OrderUp
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для обновления этого заказа"
|
||||
)
|
||||
|
||||
|
||||
# Обычные пользователи могут только отменить заказ
|
||||
if not is_admin and (order_update.status and order_update.status != OrderStatus.CANCELLED):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем заказ
|
||||
updated_order = order_repo.update_order(db, order_id, order_update, is_admin)
|
||||
|
||||
order_repo.update_order(db, order_id, order_update, is_admin)
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно обновлен",
|
||||
@ -207,21 +284,21 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для отмены этого заказа"
|
||||
)
|
||||
|
||||
|
||||
# Отменяем заказ
|
||||
order_update = OrderUpdate(status=OrderStatus.CANCELLED)
|
||||
order_repo.update_order(db, order_id, order_update, False)
|
||||
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
# Логируем событие отмены заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -231,9 +308,9 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно отменен",
|
||||
"order": order_details
|
||||
}
|
||||
}
|
||||
52
backend/check_data.py
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.models.catalog_models import Product, Category, Collection, Size
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для проверки данных в базе данных.
|
||||
"""
|
||||
print("Проверка данных в базе данных...")
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Проверяем наличие категорий
|
||||
categories = db.query(Category).all()
|
||||
print(f"Категории: {len(categories)}")
|
||||
for category in categories:
|
||||
print(f" - {category.id}: {category.name} (slug: {category.slug})")
|
||||
|
||||
# Проверяем наличие коллекций
|
||||
collections = db.query(Collection).all()
|
||||
print(f"Коллекции: {len(collections)}")
|
||||
for collection in collections:
|
||||
print(f" - {collection.id}: {collection.name} (slug: {collection.slug})")
|
||||
|
||||
# Проверяем наличие размеров
|
||||
sizes = db.query(Size).all()
|
||||
print(f"Размеры: {len(sizes)}")
|
||||
for size in sizes:
|
||||
print(f" - {size.id}: {size.name} (code: {size.code})")
|
||||
|
||||
# Проверяем наличие продуктов
|
||||
products = db.query(Product).all()
|
||||
print(f"Продукты: {len(products)}")
|
||||
for product in products[:5]: # Выводим только первые 5 продуктов
|
||||
print(f" - {product.id}: {product.name} (slug: {product.slug})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при проверке данных: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
backend/check_meilisearch.py
Normal file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для проверки данных в Meilisearch.
|
||||
"""
|
||||
print("Проверка данных в Meilisearch...")
|
||||
|
||||
try:
|
||||
# Проверяем наличие индексов
|
||||
try:
|
||||
indexes = client.get_indexes()
|
||||
print(f"Индексы: {len(indexes['results'])}")
|
||||
for index in indexes["results"]:
|
||||
print(f" - {index['uid']}: {index['primaryKey']}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении индексов: {str(e)}")
|
||||
|
||||
# Проверяем наличие категорий
|
||||
try:
|
||||
# Проверяем настройки индекса категорий
|
||||
print("\nНастройки индекса категорий:")
|
||||
filterable_attributes = client.index(CATEGORY_INDEX).get_filterable_attributes()
|
||||
print(f" Фильтруемые атрибуты: {filterable_attributes}")
|
||||
|
||||
searchable_attributes = client.index(CATEGORY_INDEX).get_searchable_attributes()
|
||||
print(f" Поисковые атрибуты: {searchable_attributes}")
|
||||
|
||||
# Получаем категории
|
||||
categories = client.index(CATEGORY_INDEX).get_documents({"limit": 100})
|
||||
print(f"\nКатегории: {len(categories['results'])}")
|
||||
for category in categories["results"]:
|
||||
print(f" - {category['id']}: {category['name']} (slug: {category['slug']})")
|
||||
|
||||
# Проверяем поиск категорий
|
||||
print("\nПоиск категорий:")
|
||||
search_result = client.index(CATEGORY_INDEX).search("", {"limit": 100})
|
||||
print(f" Найдено категорий: {len(search_result['hits'])}")
|
||||
print(f" Общее количество категорий: {search_result['estimatedTotalHits']}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении категорий: {str(e)}")
|
||||
|
||||
# Проверяем наличие коллекций
|
||||
try:
|
||||
collections = client.index(COLLECTION_INDEX).get_documents({"limit": 100})
|
||||
print(f"Коллекции: {len(collections['results'])}")
|
||||
for collection in collections["results"]:
|
||||
print(f" - {collection['id']}: {collection['name']} (slug: {collection['slug']})")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении коллекций: {str(e)}")
|
||||
|
||||
# Проверяем наличие размеров
|
||||
try:
|
||||
sizes = client.index(SIZE_INDEX).get_documents({"limit": 100})
|
||||
print(f"Размеры: {len(sizes['results'])}")
|
||||
for size in sizes["results"]:
|
||||
print(f" - {size['id']}: {size['name']} (code: {size['code']})")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении размеров: {str(e)}")
|
||||
|
||||
# Проверяем наличие продуктов
|
||||
try:
|
||||
products = client.index(PRODUCT_INDEX).get_documents({"limit": 5})
|
||||
print(f"Продукты: {len(products['results'])}")
|
||||
for product in products["results"]:
|
||||
print(f" - {product['id']}: {product['name']} (slug: {product['slug']})")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении продуктов: {str(e)}")
|
||||
|
||||
# Проверяем настройки индексов
|
||||
try:
|
||||
print("\nНастройки индекса продуктов:")
|
||||
filterable_attributes = client.index(PRODUCT_INDEX).get_filterable_attributes()
|
||||
print(f" Фильтруемые атрибуты: {filterable_attributes}")
|
||||
|
||||
searchable_attributes = client.index(PRODUCT_INDEX).get_searchable_attributes()
|
||||
print(f" Поисковые атрибуты: {searchable_attributes}")
|
||||
|
||||
sortable_attributes = client.index(PRODUCT_INDEX).get_sortable_attributes()
|
||||
print(f" Сортируемые атрибуты: {sortable_attributes}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при получении настроек индекса продуктов: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при проверке данных в Meilisearch: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -3,7 +3,8 @@
|
||||
## Содержание
|
||||
|
||||
1. [API документация](api_documentation.md) - Подробное описание всех API эндпоинтов
|
||||
2. [Структура базы данных](database_structure.md) - Описание таблиц и связей в базе данных
|
||||
2. [API заказов](orders_api.md) - Подробное описание API заказов
|
||||
3. [Структура базы данных](database_structure.md) - Описание таблиц и связей в базе данных
|
||||
|
||||
## Технологический стек
|
||||
|
||||
@ -114,4 +115,4 @@ alembic upgrade head
|
||||
После запуска приложения, документация API доступна по адресу:
|
||||
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
@ -9,31 +9,32 @@
|
||||
6. [Отзывы](#отзывы)
|
||||
7. [Контент](#контент)
|
||||
8. [Аналитика](#аналитика)
|
||||
9. [Доставка](#доставка)
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Базовый URL: `/auth`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет |
|
||||
| POST | `/login` | Вход в систему | `username` (email), `password` | Нет |
|
||||
| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет |
|
||||
| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет |
|
||||
| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет | `{"success": true, "user": {...}}` |
|
||||
| POST | `/login` | Вход в систему | `username` (email), `password` | Нет | `{"access_token": "...", "token_type": "bearer"}` |
|
||||
| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет | `{"success": true, "message": "..."}` |
|
||||
| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет | `{"success": true, "message": "..."}` |
|
||||
| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Пользователи
|
||||
|
||||
Базовый URL: `/users`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/me` | Получение профиля текущего пользователя | - | Да |
|
||||
| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да |
|
||||
| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да |
|
||||
| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/me` | Получение профиля текущего пользователя | - | Да | `{"success": true, "user": {...}}` |
|
||||
| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да | `{"success": true, "user": {...}}` |
|
||||
| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
|
||||
| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
|
||||
| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да | `{"success": true, "message": "..."}` |
|
||||
| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) | `{"success": true, "user": {...}}` |
|
||||
|
||||
## Каталог
|
||||
|
||||
@ -41,117 +42,130 @@
|
||||
|
||||
### Коллекции
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/collections` | Получение списка коллекций | `skip`, `limit` | Нет |
|
||||
| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) |
|
||||
| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) |
|
||||
| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/collections` | Получение списка коллекций | `skip`, `limit`, `search`, `is_active` | Нет | `{"success": true, "collections": [...], "total": number}` |
|
||||
| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
|
||||
| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
|
||||
| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Категории
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/categories` | Получение дерева категорий | - | Нет |
|
||||
| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/categories` | Получение списка категорий | `skip`, `limit`, `search`, `parent_id`, `is_active` | Нет | `{"success": true, "categories": [...], "total": number}` |
|
||||
| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
|
||||
| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
|
||||
| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Размеры
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/sizes` | Получение списка размеров | `skip`, `limit` | Нет |
|
||||
| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет |
|
||||
| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) |
|
||||
| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) |
|
||||
| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/sizes` | Получение списка размеров | `skip`, `limit`, `search` | Нет | `{"success": true, "sizes": [...], "total": number}` |
|
||||
| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет | `{"success": true, "size": {...}}` |
|
||||
| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
|
||||
| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
|
||||
| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) | `{"success": true}` |
|
||||
|
||||
### Продукты
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `include_variants` | Нет |
|
||||
| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет |
|
||||
| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет |
|
||||
| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `sort_by`, `sort_order` | Нет | `{"success": true, "products": [...], "total": number, "skip": number, "limit": number}` |
|
||||
| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет | `{"success": true, "product": {...}}` |
|
||||
| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет | `{"success": true, "product": {...}}` |
|
||||
| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| POST | `/products/complete` | Создание продукта с вариантами и изображениями | `ProductCreateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| PUT | `/products/{product_id}/complete` | Обновление продукта с вариантами и изображениями | `ProductUpdateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}, "variants": {...}, "images": {...}}` |
|
||||
| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Варианты продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
|
||||
| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
|
||||
| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Изображения продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file`, `is_primary` | Да (админ) |
|
||||
| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) |
|
||||
| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file` (multipart/form-data), `is_primary` (form field) | Да (админ) | `{"success": true, "image": {...}}` |
|
||||
| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) | `{"success": true, "image": {...}}` |
|
||||
| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Корзина
|
||||
|
||||
Базовый URL: `/cart`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение корзины пользователя | - | Да |
|
||||
| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (product_variant_id, quantity) | Да |
|
||||
| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да |
|
||||
| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да |
|
||||
| DELETE | `/clear` | Очистка корзины | - | Да |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/` | Получение корзины пользователя | - | Да | `{"success": true, "cart": {"items": [...], "items_count": number, "total_amount": number}}` |
|
||||
| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (variant_id, quantity) | Да | `{"success": true, "cart_item": {...}}` |
|
||||
| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да | `{"success": true, "cart_item": {...}}` |
|
||||
| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да | `{"success": true, "message": "..."}` |
|
||||
| DELETE | `/clear` | Очистка корзины | - | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Заказы
|
||||
|
||||
Базовый URL: `/orders`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да |
|
||||
| GET | `/{order_id}` | Получение информации о заказе | - | Да |
|
||||
| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method) | Да |
|
||||
| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, tracking_number) | Да (админ) |
|
||||
| POST | `/{order_id}/cancel` | Отмена заказа | - | Да |
|
||||
Подробная документация по API заказов доступна в отдельном файле: [Документация API заказов](orders_api.md)
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да | `[{"id": number, "status": string, "total_amount": number, ...}]` |
|
||||
| GET | `/{order_id}` | Получение информации о заказе | - | Да | `{"success": true, "order": {...}}` |
|
||||
| POST | `/new` | Создание нового заказа (новый формат) | `OrderCreateNew` (user_info, delivery, items, payment_method, comment) | Нет | `{"success": true, "message": "...", "order": {...}}` |
|
||||
| POST | `/` | Создание нового заказа (старый формат) | `OrderCreate` (shipping_address_id, payment_method, notes, cart_items, items) | Нет | `{"success": true, "order": {...}}` |
|
||||
| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, shipping_address_id, payment_method, payment_details, tracking_number, notes) | Да (админ) | `{"success": true, "order": {...}}` |
|
||||
| POST | `/{order_id}/cancel` | Отмена заказа | - | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Отзывы
|
||||
|
||||
Базовый URL: `/reviews`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет |
|
||||
| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да |
|
||||
| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да |
|
||||
| DELETE | `/{review_id}` | Удаление отзыва | - | Да |
|
||||
| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет | `{"success": true, "reviews": [...], "total": number}` |
|
||||
| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да | `{"success": true, "review": {...}}` |
|
||||
| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да | `{"success": true, "review": {...}}` |
|
||||
| DELETE | `/{review_id}` | Удаление отзыва | - | Да | `{"success": true, "message": "..."}` |
|
||||
| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) | `{"success": true, "review": {...}}` |
|
||||
|
||||
## Контент
|
||||
|
||||
Базовый URL: `/content`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет |
|
||||
| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет |
|
||||
| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет |
|
||||
| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) |
|
||||
| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) |
|
||||
| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет | `{"success": true, "pages": [...], "total": number}` |
|
||||
| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет | `{"success": true, "page": {...}}` |
|
||||
| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет | `{"success": true, "page": {...}}` |
|
||||
| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
|
||||
| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
|
||||
| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Аналитика
|
||||
|
||||
Базовый URL: `/analytics`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет |
|
||||
| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) |
|
||||
| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет | `{"success": true, "log": {...}}` |
|
||||
| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) | `{"success": true, "logs": [...], "total": number}` |
|
||||
| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) | `{"success": true, "report": {...}}` |
|
||||
|
||||
## Доставка
|
||||
|
||||
Базовый URL: `/delivery`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/cdek` | Обработка запросов виджета CDEK | `action` (offices, calculate) и другие параметры в зависимости от действия | Нет | Ответ от API CDEK |
|
||||
|
||||
## Модели данных
|
||||
|
||||
@ -174,12 +188,15 @@
|
||||
- `ProductVariantUpdate`: product_id, size_id, sku, stock, is_active
|
||||
- `ProductImageCreate`: product_id, image_url, alt_text, is_primary
|
||||
- `ProductImageUpdate`: alt_text, is_primary
|
||||
- `ProductCreateComplete`: включает данные продукта, список вариантов и изображений
|
||||
- `ProductUpdateComplete`: включает данные продукта, список вариантов и изображений для обновления
|
||||
|
||||
### Корзина и заказы
|
||||
- `CartItemCreate`: product_variant_id, quantity
|
||||
- `CartItemCreate`: variant_id, quantity
|
||||
- `CartItemUpdate`: quantity
|
||||
- `OrderCreate`: shipping_address_id, payment_method
|
||||
- `OrderUpdate`: status, tracking_number
|
||||
- `OrderCreateNew`: user_info (first_name, last_name, email, phone), delivery (method, address, cdek_info), items (product_id, variant_id, quantity, price), payment_method, comment
|
||||
- `OrderCreate`: shipping_address_id, payment_method, notes, cart_items, items
|
||||
- `OrderUpdate`: status, shipping_address_id, payment_method, payment_details, tracking_number, notes, delivery_method, city, delivery_address, cdek_info, courier_info, user_info_json
|
||||
|
||||
### Отзывы
|
||||
- `ReviewCreate`: product_id, rating, text
|
||||
@ -188,4 +205,4 @@
|
||||
### Контент
|
||||
- `PageCreate`: title, slug, content, is_published
|
||||
- `PageUpdate`: title, slug, content, is_published
|
||||
- `AnalyticsLogCreate`: event_type, event_data, user_id
|
||||
- `AnalyticsLogCreate`: event_type, event_data, user_id
|
||||
@ -128,11 +128,20 @@
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| status | String | Статус заказа (новый, оплачен, отправлен, доставлен, отменен) |
|
||||
| status | String | Статус заказа (pending, processing, shipped, delivered, cancelled, refunded) |
|
||||
| total_amount | Float | Общая сумма заказа |
|
||||
| shipping_address_id | Integer | Внешний ключ на таблицу addresses |
|
||||
| payment_method | String | Способ оплаты |
|
||||
| tracking_number | String | Номер отслеживания (опционально) |
|
||||
| user_info_json | JSON | JSON с информацией о пользователе |
|
||||
| delivery_method | String | Способ доставки (cdek, courier) |
|
||||
| city | String | Город доставки |
|
||||
| delivery_address | String | Адрес доставки (форматированный) |
|
||||
| cdek_info | JSON | Информация о доставке CDEK |
|
||||
| courier_info | JSON | Информация о курьерской доставке |
|
||||
| shipping_address_id | Integer | Внешний ключ на таблицу addresses (для обратной совместимости) |
|
||||
| payment_method | String | Способ оплаты (card, sbp) |
|
||||
| payment_details | String | Детали оплаты |
|
||||
| items_json | JSON | JSON со списком заказанных товаров |
|
||||
| tracking_number | String | Номер отслеживания |
|
||||
| notes | String | Примечания к заказу |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
@ -207,4 +216,4 @@
|
||||
13. `product_variants` 1:N `order_items` (вариант продукта может быть в нескольких заказах)
|
||||
|
||||
14. `orders` 1:N `order_items` (заказ может содержать несколько товаров)
|
||||
15. `addresses` 1:N `orders` (адрес может быть использован в нескольких заказах)
|
||||
15. `addresses` 1:N `orders` (адрес может быть использован в нескольких заказах)
|
||||
594
backend/docs/orders_api.md
Normal file
@ -0,0 +1,594 @@
|
||||
# Документация API заказов
|
||||
|
||||
## Содержание
|
||||
1. [Общая информация](#общая-информация)
|
||||
2. [Эндпоинты](#эндпоинты)
|
||||
- [Получение списка заказов](#получение-списка-заказов)
|
||||
- [Получение информации о заказе](#получение-информации-о-заказе)
|
||||
- [Создание заказа (новый формат)](#создание-заказа-новый-формат)
|
||||
- [Создание заказа (старый формат)](#создание-заказа-старый-формат)
|
||||
- [Обновление заказа](#обновление-заказа)
|
||||
- [Отмена заказа](#отмена-заказа)
|
||||
3. [Модели данных](#модели-данных)
|
||||
- [Структура заказа](#структура-заказа)
|
||||
- [Структура элемента заказа](#структура-элемента-заказа)
|
||||
- [Структура создания заказа (новый формат)](#структура-создания-заказа-новый-формат)
|
||||
- [Структура создания заказа (старый формат)](#структура-создания-заказа-старый-формат)
|
||||
- [Структура обновления заказа](#структура-обновления-заказа)
|
||||
|
||||
## Общая информация
|
||||
|
||||
Базовый URL: `/orders`
|
||||
|
||||
API заказов позволяет:
|
||||
- Получать список заказов пользователя
|
||||
- Получать детальную информацию о заказе
|
||||
- Создавать новые заказы
|
||||
- Обновлять существующие заказы
|
||||
- Отменять заказы
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
### Получение списка заказов
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
GET /orders
|
||||
```
|
||||
|
||||
**Параметры запроса:**
|
||||
- `skip` (опционально): Количество пропускаемых записей (по умолчанию: 0)
|
||||
- `limit` (опционально): Максимальное количество возвращаемых записей (по умолчанию: 100)
|
||||
- `status` (опционально): Фильтр по статусу заказа (pending, processing, shipped, delivered, cancelled, refunded)
|
||||
|
||||
**Требуется авторизация:** Да
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "pending",
|
||||
"total_amount": 5990.0,
|
||||
"user_info_json": {
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phone": "+7 (999) 123-45-67"
|
||||
},
|
||||
"delivery_method": "cdek",
|
||||
"city": "Москва",
|
||||
"delivery_address": "Москва, ул. Примерная, д. 1",
|
||||
"cdek_info": {
|
||||
"pvz": {
|
||||
"code": "MSK123",
|
||||
"address": "ул. Примерная, д. 1"
|
||||
}
|
||||
},
|
||||
"payment_method": "card",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T12:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Получение информации о заказе
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
GET /orders/{order_id}
|
||||
```
|
||||
|
||||
**Параметры запроса:**
|
||||
- `order_id`: ID заказа
|
||||
|
||||
**Требуется авторизация:** Да
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"order": {
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "pending",
|
||||
"total_amount": 5990.0,
|
||||
"user_info_json": {
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phone": "+7 (999) 123-45-67"
|
||||
},
|
||||
"delivery_method": "cdek",
|
||||
"city": "Москва",
|
||||
"delivery_address": "Москва, ул. Примерная, д. 1",
|
||||
"cdek_info": {
|
||||
"pvz": {
|
||||
"code": "MSK123",
|
||||
"address": "ул. Примерная, д. 1"
|
||||
}
|
||||
},
|
||||
"payment_method": "card",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T12:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Создание заказа (новый формат)
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
POST /orders/new
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"user_info": {
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phone": "+7 (999) 123-45-67"
|
||||
},
|
||||
"delivery": {
|
||||
"method": "cdek",
|
||||
"address": {
|
||||
"city": "Москва",
|
||||
"street": "Примерная",
|
||||
"house": "1",
|
||||
"apartment": "123",
|
||||
"postal_code": "123456",
|
||||
"formatted_address": "Москва, ул. Примерная, д. 1, кв. 123"
|
||||
},
|
||||
"cdek_info": {
|
||||
"pvz": {
|
||||
"code": "MSK123",
|
||||
"city_code": 44,
|
||||
"city": "Москва",
|
||||
"address": "ул. Примерная, д. 1",
|
||||
"work_time": "Пн-Вс 10:00-20:00",
|
||||
"location": [37.123, 55.456]
|
||||
},
|
||||
"tariff": {
|
||||
"tariff_code": 136,
|
||||
"tariff_name": "Посылка склад-склад",
|
||||
"delivery_sum": 300,
|
||||
"period_min": 1,
|
||||
"period_max": 2
|
||||
},
|
||||
"delivery_type": "office"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"product_id": 456,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0
|
||||
}
|
||||
],
|
||||
"payment_method": "card",
|
||||
"comment": "Позвоните перед доставкой"
|
||||
}
|
||||
```
|
||||
|
||||
**Требуется авторизация:** Нет (опционально)
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Заказ успешно создан",
|
||||
"order_id": 1,
|
||||
"order": {
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "pending",
|
||||
"total_amount": 5990.0,
|
||||
"user_info_json": {
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phone": "+7 (999) 123-45-67"
|
||||
},
|
||||
"delivery_method": "cdek",
|
||||
"city": "Москва",
|
||||
"delivery_address": "Москва, ул. Примерная, д. 1, кв. 123",
|
||||
"cdek_info": {
|
||||
"pvz": {
|
||||
"code": "MSK123",
|
||||
"city_code": 44,
|
||||
"city": "Москва",
|
||||
"address": "ул. Примерная, д. 1",
|
||||
"work_time": "Пн-Вс 10:00-20:00",
|
||||
"location": [37.123, 55.456]
|
||||
},
|
||||
"tariff": {
|
||||
"tariff_code": 136,
|
||||
"tariff_name": "Посылка склад-склад",
|
||||
"delivery_sum": 300,
|
||||
"period_min": 1,
|
||||
"period_max": 2
|
||||
},
|
||||
"delivery_type": "office"
|
||||
},
|
||||
"payment_method": "card",
|
||||
"notes": "Позвоните перед доставкой",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T12:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"user_message": "Для вас был создан аккаунт с email: ivan@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Создание заказа (старый формат)
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
POST /orders
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"shipping_address_id": 1,
|
||||
"payment_method": "card",
|
||||
"notes": "Позвоните перед доставкой",
|
||||
"cart_items": [1, 2, 3],
|
||||
"items": [
|
||||
{
|
||||
"variant_id": 123,
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Требуется авторизация:** Нет (опционально)
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Заказ успешно создан",
|
||||
"order": {
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "pending",
|
||||
"total_amount": 5990.0,
|
||||
"shipping_address_id": 1,
|
||||
"shipping_address": {
|
||||
"id": 1,
|
||||
"address_line1": "ул. Примерная, д. 1",
|
||||
"address_line2": "кв. 123",
|
||||
"city": "Москва",
|
||||
"state": "",
|
||||
"postal_code": "123456",
|
||||
"country": "Россия",
|
||||
"is_default": true
|
||||
},
|
||||
"payment_method": "card",
|
||||
"notes": "Позвоните перед доставкой",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T12:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Обновление заказа
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
PUT /orders/{order_id}
|
||||
```
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"status": "cancelled",
|
||||
"shipping_address_id": 2,
|
||||
"payment_method": "card",
|
||||
"payment_details": "Оплата картой при получении",
|
||||
"tracking_number": "TRACK123456",
|
||||
"notes": "Новый комментарий к заказу"
|
||||
}
|
||||
```
|
||||
|
||||
**Требуется авторизация:** Да (обычные пользователи могут только отменять заказы)
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Заказ успешно обновлен",
|
||||
"order": {
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "cancelled",
|
||||
"total_amount": 5990.0,
|
||||
"shipping_address_id": 2,
|
||||
"shipping_address": {
|
||||
"id": 2,
|
||||
"address_line1": "ул. Новая, д. 2",
|
||||
"address_line2": "кв. 456",
|
||||
"city": "Москва",
|
||||
"state": "",
|
||||
"postal_code": "654321",
|
||||
"country": "Россия",
|
||||
"is_default": false
|
||||
},
|
||||
"payment_method": "card",
|
||||
"payment_details": "Оплата картой при получении",
|
||||
"tracking_number": "TRACK123456",
|
||||
"notes": "Новый комментарий к заказу",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T13:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Отмена заказа
|
||||
|
||||
**Запрос:**
|
||||
```
|
||||
POST /orders/{order_id}/cancel
|
||||
```
|
||||
|
||||
**Требуется авторизация:** Да
|
||||
|
||||
**Формат ответа:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Заказ успешно отменен",
|
||||
"order": {
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"status": "cancelled",
|
||||
"total_amount": 5990.0,
|
||||
"shipping_address_id": 1,
|
||||
"shipping_address": {
|
||||
"id": 1,
|
||||
"address_line1": "ул. Примерная, д. 1",
|
||||
"address_line2": "кв. 123",
|
||||
"city": "Москва",
|
||||
"state": "",
|
||||
"postal_code": "123456",
|
||||
"country": "Россия",
|
||||
"is_default": true
|
||||
},
|
||||
"payment_method": "card",
|
||||
"notes": "Позвоните перед доставкой",
|
||||
"created_at": "2023-06-01T12:00:00",
|
||||
"updated_at": "2023-06-01T13:00:00",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_id": 1,
|
||||
"variant_id": 123,
|
||||
"quantity": 1,
|
||||
"price": 5990.0,
|
||||
"product_id": 456,
|
||||
"product_name": "Платье летнее",
|
||||
"product_image": "dress.jpg",
|
||||
"variant_name": "M",
|
||||
"size": "M",
|
||||
"total_price": 5990.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Модели данных
|
||||
|
||||
### Структура заказа
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| status | String | Статус заказа (pending, processing, shipped, delivered, cancelled, refunded) |
|
||||
| total_amount | Float | Общая сумма заказа |
|
||||
| user_info_json | JSON | JSON с информацией о пользователе |
|
||||
| delivery_method | String | Способ доставки (cdek, courier) |
|
||||
| city | String | Город доставки |
|
||||
| delivery_address | String | Адрес доставки (форматированный) |
|
||||
| cdek_info | JSON | Информация о доставке CDEK |
|
||||
| courier_info | JSON | Информация о курьерской доставке |
|
||||
| shipping_address_id | Integer | Внешний ключ на таблицу addresses (для обратной совместимости) |
|
||||
| payment_method | String | Способ оплаты (card, sbp) |
|
||||
| payment_details | String | Детали оплаты |
|
||||
| items_json | JSON | JSON со списком заказанных товаров |
|
||||
| tracking_number | String | Номер отслеживания |
|
||||
| notes | String | Примечания к заказу |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Структура элемента заказа
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| order_id | Integer | Внешний ключ на таблицу orders |
|
||||
| variant_id | Integer | Внешний ключ на таблицу product_variants |
|
||||
| quantity | Integer | Количество товара |
|
||||
| price | Float | Цена товара на момент заказа |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
|
||||
### Структура создания заказа (новый формат)
|
||||
|
||||
```typescript
|
||||
interface UserInfo {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface Address {
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment?: string;
|
||||
postal_code?: string;
|
||||
formatted_address?: string;
|
||||
}
|
||||
|
||||
interface CdekPvz {
|
||||
code: string;
|
||||
city_code: number;
|
||||
city: string;
|
||||
address: string;
|
||||
work_time?: string;
|
||||
location?: [number, number];
|
||||
}
|
||||
|
||||
interface CdekTariff {
|
||||
tariff_code: number;
|
||||
tariff_name: string;
|
||||
delivery_sum: number;
|
||||
period_min: number;
|
||||
period_max: number;
|
||||
}
|
||||
|
||||
interface CdekInfo {
|
||||
pvz: CdekPvz;
|
||||
tariff: CdekTariff;
|
||||
delivery_type: string;
|
||||
}
|
||||
|
||||
interface DeliveryInfo {
|
||||
method: string;
|
||||
address: Address;
|
||||
cdek_info?: CdekInfo;
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
product_id: number;
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface OrderCreateNew {
|
||||
user_info: UserInfo;
|
||||
delivery: DeliveryInfo;
|
||||
items: OrderItem[];
|
||||
payment_method: string;
|
||||
comment?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Структура создания заказа (старый формат)
|
||||
|
||||
```typescript
|
||||
interface OrderItemCreate {
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface OrderCreate {
|
||||
shipping_address_id?: number;
|
||||
payment_method?: string;
|
||||
notes?: string;
|
||||
cart_items?: number[];
|
||||
items?: OrderItemCreate[];
|
||||
}
|
||||
```
|
||||
|
||||
### Структура обновления заказа
|
||||
|
||||
```typescript
|
||||
interface OrderUpdate {
|
||||
status?: string;
|
||||
shipping_address_id?: number;
|
||||
payment_method?: string;
|
||||
payment_details?: string;
|
||||
tracking_number?: string;
|
||||
notes?: string;
|
||||
delivery_method?: string;
|
||||
city?: string;
|
||||
delivery_address?: string;
|
||||
cdek_info?: any;
|
||||
courier_info?: any;
|
||||
user_info_json?: any;
|
||||
}
|
||||
```
|
||||
@ -5,7 +5,6 @@ aiohttp==3.11.11
|
||||
aiosignal==1.3.2
|
||||
amqp==5.3.1
|
||||
annotated-types==0.6.0
|
||||
anthropic==0.45.2
|
||||
anyio==4.2.0
|
||||
argcomplete==1.10.3
|
||||
asyncpg==0.30.0
|
||||
@ -27,7 +26,6 @@ extract-msg==0.28.7
|
||||
fastapi==0.109.0
|
||||
frozenlist==1.5.0
|
||||
greenlet==3.0.3
|
||||
groq==0.16.0
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
@ -43,7 +41,6 @@ multidict==6.1.0
|
||||
multivolumefile==0.2.3
|
||||
numpy==2.2.2
|
||||
olefile==0.47
|
||||
openai==1.61.0
|
||||
openpyxl==3.1.5
|
||||
outcome==1.3.0.post0
|
||||
packaging==24.2
|
||||
@ -79,7 +76,7 @@ SQLAlchemy==2.0.25
|
||||
starlette==0.35.1
|
||||
taskiq==0.11.10
|
||||
taskiq-dependencies==1.5.6
|
||||
taskiq-redis==1.0.2
|
||||
|
||||
tenacity==9.0.0
|
||||
texttable==1.7.0
|
||||
tqdm==4.67.1
|
||||
@ -104,5 +101,6 @@ bcrypt<4.0.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
setuptools==75.8.0
|
||||
cachetools
|
||||
boto3
|
||||
meilisearch==0.28.0
|
||||
|
||||
|
||||
169
backend/requirements_new.txt
Normal file
@ -0,0 +1,169 @@
|
||||
# Основные зависимости
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.34.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
pydantic_core==2.14.6
|
||||
starlette==0.35.1
|
||||
typing_extensions==4.12.2
|
||||
|
||||
# Валидация данных
|
||||
marshmallow==3.21.1
|
||||
cerberus==1.3.5
|
||||
jsonschema==4.21.1
|
||||
|
||||
# База данных
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
greenlet==3.0.3 # Требуется для SQLAlchemy с asyncpg
|
||||
|
||||
# Аутентификация и безопасность
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt<4.0.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
pycryptodome==3.21.0
|
||||
pycryptodomex==3.21.0
|
||||
PyJWT==2.8.0
|
||||
|
||||
# HTTP и файлы
|
||||
httpx==0.28.1
|
||||
aiofiles==24.1.0
|
||||
anyio==4.2.0 # Требуется для FastAPI и httpx
|
||||
|
||||
# CORS
|
||||
django-cors-headers==4.3.1 # Для Django проектов
|
||||
flask-cors==4.0.0 # Для Flask проектов
|
||||
|
||||
# WebSocket
|
||||
websocket-client==1.8.0
|
||||
trio==0.28.0
|
||||
trio-websocket==0.11.1
|
||||
wsproto==1.2.0
|
||||
python-socketio==5.11.1
|
||||
websockets==12.0
|
||||
|
||||
# Поиск и индексация
|
||||
meilisearch==0.28.0
|
||||
|
||||
# Облачное хранилище
|
||||
boto3==1.34.69
|
||||
|
||||
# Кэширование
|
||||
redis==5.0.3
|
||||
aioredis==2.0.1
|
||||
|
||||
# Очереди сообщений
|
||||
amqp==5.3.1
|
||||
kombu==5.4.2
|
||||
aiokafka==0.10.0
|
||||
|
||||
# Асинхронные задачи
|
||||
taskiq==0.11.10
|
||||
taskiq-dependencies==1.5.6
|
||||
|
||||
# Многопоточность и параллелизм
|
||||
futures==3.1.1
|
||||
concurrent-log-handler==0.9.24
|
||||
|
||||
# API документация
|
||||
openapi-spec-validator==0.7.1
|
||||
swagger-ui-bundle==0.0.9
|
||||
pydantic-openapi-schema==1.0.1
|
||||
|
||||
# GraphQL
|
||||
strawberry-graphql==0.219.2
|
||||
graphene==3.3
|
||||
graphene-sqlalchemy==2.3.0
|
||||
|
||||
# Электронная почта
|
||||
fastapi-mail==1.4.1
|
||||
|
||||
# Работа с датами и временем
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.1
|
||||
|
||||
# Интернационализация и локализация
|
||||
babel==2.14.0
|
||||
python-gettext==5.0
|
||||
polib==1.2.0
|
||||
|
||||
# Анализ данных
|
||||
pandas==2.2.3
|
||||
numpy==2.2.2
|
||||
|
||||
# Машинное обучение
|
||||
scikit-learn==1.4.1
|
||||
joblib==1.3.2
|
||||
|
||||
# Обработка естественного языка (NLP)
|
||||
nltk==3.8.1
|
||||
spacy==3.7.4
|
||||
|
||||
# Визуализация данных
|
||||
matplotlib==3.8.4
|
||||
seaborn==0.13.2
|
||||
plotly==5.22.0
|
||||
|
||||
# Работа с Excel и другими форматами файлов
|
||||
openpyxl==3.1.5
|
||||
xlrd==1.2.0
|
||||
XlsxWriter==3.2.2
|
||||
|
||||
# Работа с PDF и документами
|
||||
pdfminer.six==20191110
|
||||
python-pptx==0.6.23
|
||||
PyPDF2==3.0.1
|
||||
reportlab==4.1.0
|
||||
python-docx==1.1.0
|
||||
docx2txt==0.8
|
||||
|
||||
# Работа с архивами
|
||||
py7zr==0.22.0
|
||||
pyzstd==0.16.2
|
||||
rarfile==4.2
|
||||
|
||||
# Работа с XML и HTML
|
||||
lxml==5.3.0
|
||||
beautifulsoup4==4.12.3
|
||||
soupsieve==2.6
|
||||
|
||||
# Веб-скрапинг
|
||||
scrapy==2.11.1
|
||||
requests-html==0.10.0
|
||||
|
||||
# Работа с форматами данными
|
||||
pyyaml==6.0.1
|
||||
toml==0.10.2
|
||||
|
||||
# Работа с геоданными
|
||||
geopy==2.4.1
|
||||
|
||||
# Интеграция с мессенджерами
|
||||
aiogram==3.17.0
|
||||
aiohttp==3.11.11
|
||||
|
||||
# Планировщик задач
|
||||
pycron==3.1.2
|
||||
|
||||
# Мониторинг и логирование
|
||||
prometheus-client==0.20.0
|
||||
sentry-sdk==2.12.1
|
||||
loguru==0.7.2
|
||||
structlog==24.1.0
|
||||
python-json-logger==2.0.7
|
||||
|
||||
# Тестирование
|
||||
pytest==8.0.2
|
||||
pytest-asyncio==0.23.5
|
||||
pytest-cov==5.0.0
|
||||
httpx==0.28.1 # Для тестирования FastAPI
|
||||
|
||||
# Утилиты
|
||||
python-dotenv==1.0.0
|
||||
setuptools==75.8.0
|
||||
jinja2==3.1.3 # Для шаблонов (если используется)
|
||||
pillow==11.1.0 # Для обработки изображений
|
||||
39
backend/sync_categories.py
Normal file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.scripts.sync_meilisearch import sync_categories
|
||||
from app.services.meilisearch_service import initialize_indexes
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для принудительной синхронизации категорий с Meilisearch.
|
||||
"""
|
||||
print("Принудительная синхронизация категорий с Meilisearch...")
|
||||
|
||||
# Инициализируем индексы в Meilisearch
|
||||
initialize_indexes()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Синхронизируем категории с Meilisearch
|
||||
success = sync_categories(db)
|
||||
|
||||
if success:
|
||||
print("Категории успешно синхронизированы с Meilisearch")
|
||||
else:
|
||||
print("Ошибка при синхронизации категорий с Meilisearch")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
115
backend/sync_categories_direct.py
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.models.catalog_models import Category
|
||||
from app.services.meilisearch_service import client, CATEGORY_INDEX
|
||||
|
||||
def format_category_for_meilisearch(category):
|
||||
"""
|
||||
Форматирует категорию для индексации в Meilisearch.
|
||||
"""
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"description": category.description,
|
||||
"parent_id": category.parent_id,
|
||||
"is_active": category.is_active,
|
||||
"created_at": category.created_at.isoformat() if category.created_at else None,
|
||||
"updated_at": category.updated_at.isoformat() if category.updated_at else None
|
||||
}
|
||||
|
||||
def sync_categories(db):
|
||||
"""
|
||||
Синхронизирует все категории с Meilisearch.
|
||||
"""
|
||||
print("Синхронизация категорий...")
|
||||
|
||||
# Получаем все категории из базы данных
|
||||
categories = db.query(Category).all()
|
||||
|
||||
# Форматируем категории для Meilisearch
|
||||
categories_data = [format_category_for_meilisearch(category) for category in categories]
|
||||
|
||||
# Синхронизируем категории с Meilisearch
|
||||
try:
|
||||
# Удаляем все документы из индекса категорий
|
||||
client.index(CATEGORY_INDEX).delete_all_documents()
|
||||
|
||||
# Добавляем новые документы
|
||||
if categories_data:
|
||||
client.index(CATEGORY_INDEX).add_documents(categories_data)
|
||||
|
||||
print(f"Успешно синхронизировано {len(categories_data)} категорий")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации категорий: {str(e)}")
|
||||
return False
|
||||
|
||||
def initialize_category_index():
|
||||
"""
|
||||
Инициализирует индекс категорий в Meilisearch.
|
||||
"""
|
||||
try:
|
||||
# Проверяем, существует ли индекс категорий
|
||||
indexes = client.get_indexes()
|
||||
index_exists = False
|
||||
|
||||
for index in indexes["results"]:
|
||||
if index["uid"] == CATEGORY_INDEX:
|
||||
index_exists = True
|
||||
break
|
||||
|
||||
# Если индекс не существует, создаем его
|
||||
if not index_exists:
|
||||
client.create_index(CATEGORY_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для категорий
|
||||
client.index(CATEGORY_INDEX).update_filterable_attributes([
|
||||
"parent_id", "is_active", "id", "slug"
|
||||
])
|
||||
|
||||
# Настраиваем поисковые атрибуты для категорий
|
||||
client.index(CATEGORY_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
print("Индекс категорий инициализирован успешно")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка при инициализации индекса категорий: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для принудительной синхронизации категорий с Meilisearch.
|
||||
"""
|
||||
print("Принудительная синхронизация категорий с Meilisearch...")
|
||||
|
||||
# Инициализируем индекс категорий в Meilisearch
|
||||
initialize_category_index()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Синхронизируем категории с Meilisearch
|
||||
success = sync_categories(db)
|
||||
|
||||
if success:
|
||||
print("Категории успешно синхронизированы с Meilisearch")
|
||||
else:
|
||||
print("Ошибка при синхронизации категорий с Meilisearch")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
43
backend/sync_meilisearch.py
Normal file
@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
|
||||
from app.services import meilisearch_service
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для ручной синхронизации данных с Meilisearch.
|
||||
"""
|
||||
print("Инициализация индексов Meilisearch...")
|
||||
meilisearch_service.initialize_indexes()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
print("Синхронизация категорий...")
|
||||
sync_categories(db)
|
||||
|
||||
print("Синхронизация коллекций...")
|
||||
sync_collections(db)
|
||||
|
||||
print("Синхронизация размеров...")
|
||||
sync_sizes(db)
|
||||
|
||||
print("Синхронизация продуктов...")
|
||||
sync_products(db)
|
||||
|
||||
print("Синхронизация с Meilisearch завершена успешно!")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
backend/sync_sizes_direct.py
Normal file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.core import SessionLocal
|
||||
from app.models.catalog_models import Size
|
||||
from app.services.meilisearch_service import client, SIZE_INDEX
|
||||
|
||||
def format_size_for_meilisearch(size):
|
||||
"""
|
||||
Форматирует размер для индексации в Meilisearch.
|
||||
"""
|
||||
return {
|
||||
"id": size.id,
|
||||
"name": size.name,
|
||||
"code": size.code,
|
||||
"description": size.description,
|
||||
"created_at": size.created_at.isoformat() if size.created_at else None,
|
||||
"updated_at": size.updated_at.isoformat() if size.updated_at else None
|
||||
}
|
||||
|
||||
def sync_sizes(db):
|
||||
"""
|
||||
Синхронизирует все размеры с Meilisearch.
|
||||
"""
|
||||
print("Синхронизация размеров...")
|
||||
|
||||
# Получаем все размеры из базы данных
|
||||
sizes = db.query(Size).all()
|
||||
|
||||
# Форматируем размеры для Meilisearch
|
||||
sizes_data = [format_size_for_meilisearch(size) for size in sizes]
|
||||
|
||||
# Синхронизируем размеры с Meilisearch
|
||||
try:
|
||||
# Удаляем все документы из индекса размеров
|
||||
client.index(SIZE_INDEX).delete_all_documents()
|
||||
|
||||
# Добавляем новые документы
|
||||
if sizes_data:
|
||||
client.index(SIZE_INDEX).add_documents(sizes_data)
|
||||
|
||||
print(f"Успешно синхронизировано {len(sizes_data)} размеров")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации размеров: {str(e)}")
|
||||
return False
|
||||
|
||||
def initialize_size_index():
|
||||
"""
|
||||
Инициализирует индекс размеров в Meilisearch.
|
||||
"""
|
||||
try:
|
||||
# Проверяем, существует ли индекс размеров
|
||||
indexes = client.get_indexes()
|
||||
index_exists = False
|
||||
|
||||
for index in indexes["results"]:
|
||||
if index["uid"] == SIZE_INDEX:
|
||||
index_exists = True
|
||||
break
|
||||
|
||||
# Если индекс не существует, создаем его
|
||||
if not index_exists:
|
||||
client.create_index(SIZE_INDEX, {"primaryKey": "id"})
|
||||
|
||||
# Настраиваем фильтруемые атрибуты для размеров
|
||||
client.index(SIZE_INDEX).update_filterable_attributes([
|
||||
"id", "code"
|
||||
])
|
||||
|
||||
# Настраиваем поисковые атрибуты для размеров
|
||||
client.index(SIZE_INDEX).update_searchable_attributes([
|
||||
"name", "code", "description"
|
||||
])
|
||||
|
||||
print("Индекс размеров инициализирован успешно")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка при инициализации индекса размеров: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для принудительной синхронизации размеров с Meilisearch.
|
||||
"""
|
||||
print("Принудительная синхронизация размеров с Meilisearch...")
|
||||
|
||||
# Инициализируем индекс размеров в Meilisearch
|
||||
initialize_size_index()
|
||||
|
||||
# Создаем сессию базы данных
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Синхронизируем размеры с Meilisearch
|
||||
success = sync_sizes(db)
|
||||
|
||||
if success:
|
||||
print("Размеры успешно синхронизированы с Meilisearch")
|
||||
else:
|
||||
print("Ошибка при синхронизации размеров с Meilisearch")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при синхронизации размеров с Meilisearch: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
backend/update_meilisearch_settings.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX
|
||||
|
||||
def main():
|
||||
"""
|
||||
Скрипт для обновления настроек индексов Meilisearch.
|
||||
"""
|
||||
print("Обновление настроек индексов Meilisearch...")
|
||||
|
||||
try:
|
||||
# Обновляем фильтруемые атрибуты для продуктов
|
||||
print("Обновление фильтруемых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_filterable_attributes([
|
||||
"category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
|
||||
])
|
||||
|
||||
# Обновляем поисковые атрибуты для продуктов
|
||||
print("Обновление поисковых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Обновляем сортируемые атрибуты для продуктов
|
||||
print("Обновление сортируемых атрибутов для продуктов...")
|
||||
client.index(PRODUCT_INDEX).update_sortable_attributes([
|
||||
"price", "discount_price", "created_at", "name"
|
||||
])
|
||||
|
||||
# Обновляем фильтруемые атрибуты для категорий
|
||||
print("Обновление фильтруемых атрибутов для категорий...")
|
||||
client.index(CATEGORY_INDEX).update_filterable_attributes([
|
||||
"parent_id", "is_active", "id", "slug"
|
||||
])
|
||||
|
||||
# Обновляем поисковые атрибуты для категорий
|
||||
print("Обновление поисковых атрибутов для категорий...")
|
||||
client.index(CATEGORY_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Обновляем фильтруемые атрибуты для коллекций
|
||||
print("Обновление фильтруемых атрибутов для коллекций...")
|
||||
client.index(COLLECTION_INDEX).update_filterable_attributes([
|
||||
"is_active", "id", "slug"
|
||||
])
|
||||
|
||||
# Обновляем поисковые атрибуты для коллекций
|
||||
print("Обновление поисковых атрибутов для коллекций...")
|
||||
client.index(COLLECTION_INDEX).update_searchable_attributes([
|
||||
"name", "description", "slug"
|
||||
])
|
||||
|
||||
# Обновляем поисковые атрибуты для размеров
|
||||
print("Обновление поисковых атрибутов для размеров...")
|
||||
client.index(SIZE_INDEX).update_searchable_attributes([
|
||||
"name", "code", "description"
|
||||
])
|
||||
|
||||
print("Настройки индексов Meilisearch обновлены успешно!")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при обновлении настроек индексов Meilisearch: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg
Normal file
|
After Width: | Height: | Size: 796 KiB |
BIN
backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg
Normal file
|
After Width: | Height: | Size: 255 KiB |
@ -9,19 +9,24 @@ services:
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
# - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- DEBUG=0
|
||||
- SECRET_KEY=${SECRET_KEY:-supersecretkey}
|
||||
- UPLOAD_DIRECTORY=/app/uploads
|
||||
- MEILISEARCH_URL=http://meilisearch:7700
|
||||
- MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- backend
|
||||
- fastapi
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
|
||||
@ -42,6 +47,9 @@ services:
|
||||
- NEXT_PUBLIC_API_URL=https://${DOMAIN_NAME}/api
|
||||
- NEXT_PUBLIC_BASE_URL=https://${DOMAIN_NAME}
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- frontend_next_prod:/app/.next # сохраняем сборку в отдельном томе
|
||||
command: sh -c "npm run build && npm start" # явно запускаем сборку перед стартом
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@ -105,10 +113,50 @@ services:
|
||||
- postgres
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: dressed-for-success-redis
|
||||
hostname: redis
|
||||
expose:
|
||||
- "6379"
|
||||
restart: always
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- redis
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
container_name: dressed-for-success-meilisearch
|
||||
hostname: meilisearch
|
||||
expose:
|
||||
- "7700"
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
- MEILI_NO_ANALYTICS=true
|
||||
- MEILI_ENV=production
|
||||
volumes:
|
||||
- meilisearch_data:/data.ms
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
restart: always
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- meilisearch
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
driver: local
|
||||
meilisearch_data:
|
||||
driver: local
|
||||
frontend_next_prod:
|
||||
driver: local
|
||||
@ -1,104 +1,206 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
# --- Backend Service ---
|
||||
fastapi:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: dressed-for-success-backend
|
||||
hostname: backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- DEBUG=0
|
||||
- SECRET_KEY=supersecretkey
|
||||
- UPLOAD_DIRECTORY=/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
# Не монтируем код в продакшене, он уже скопирован в образ
|
||||
# volumes:
|
||||
# - ./backend:/app
|
||||
# Используем CMD из базового образа tiangolo/uvicorn-gunicorn-fastapi (Gunicorn + Uvicorn workers)
|
||||
# command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # Это для разработки
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
expose:
|
||||
- "8000" # Внутренний порт, доступный другим сервисам в сети
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- backend
|
||||
dns_search: .
|
||||
- app-network
|
||||
environment:
|
||||
# Переменная для подключения к MeiliSearch внутри Docker-сети
|
||||
- MEILISEARCH_URL=http://meilisearch:7700
|
||||
- MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
# Добавьте сюда другие переменные окружения для бэкенда (ключи API, настройки БД и т.д.)
|
||||
# - DATABASE_URL=...
|
||||
# - SECRET_KEY=...
|
||||
depends_on:
|
||||
- meilisearch # Запускать после MeiliSearch
|
||||
restart: unless-stopped
|
||||
|
||||
# --- Frontend Service ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
# Не монтируем исходный код в продакшене
|
||||
# volumes:
|
||||
# - ./frontend:/app
|
||||
# Сохраняем .next для возможного ускорения перезапусков (опционально)
|
||||
# volumes:
|
||||
# - frontend_next:/app/.next
|
||||
# Команда запускает уже собранное приложение
|
||||
command: npm start
|
||||
expose:
|
||||
- "3000" # Внутренний порт для Next.js сервера
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
# URL для API-запросов из БРАУЗЕРА (через Nginx)
|
||||
# Браузер будет обращаться к /api/... на том же домене/хосте
|
||||
- NEXT_PUBLIC_API_URL=/api
|
||||
# URL для API-запросов со стороны СЕРВЕРА Next.js (SSR, API Routes)
|
||||
# Используем внутреннее имя сервиса Docker для серверных запросов
|
||||
- NEXT_SERVER_API_URL=http://fastapi/api
|
||||
# URL для доступа к MinIO (для изображений)
|
||||
- NEXT_PUBLIC_MINIO_URL=http://45.129.128.113:9000
|
||||
# Включаем режим отладки для логирования URL изображений
|
||||
- NEXT_PUBLIC_DEBUG=true
|
||||
# Убедитесь, что ваш Next.js код использует INTERNAL_API_URL для серверных запросов
|
||||
# и NEXT_PUBLIC_API_URL (или просто относительный путь /api) для клиентских
|
||||
depends_on:
|
||||
- fastapi # Желательно запускать после бэкенда
|
||||
restart: unless-stopped
|
||||
|
||||
# --- PHP Service ---
|
||||
php:
|
||||
image: php:8.2-apache
|
||||
volumes:
|
||||
- ./php:/var/www/html # Монтируем PHP скрипты
|
||||
expose:
|
||||
- "80" # Apache внутри контейнера слушает порт 80
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
# --- Nginx Reverse Proxy ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
# Монтируем наш конфиг Nginx (только для чтения)
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
# Опционально: монтируем статику, если она не раздается через Next.js/FastAPI
|
||||
# - ./static:/usr/share/nginx/html/static:ro
|
||||
ports:
|
||||
# Единственная точка входа: порт 80 хоста -> порт 80 Nginx
|
||||
- "80:80"
|
||||
# Если нужен HTTPS (рекомендуется), добавьте:
|
||||
# - "443:443"
|
||||
# И настройте SSL в nginx.conf, монтируя сертификаты
|
||||
depends_on:
|
||||
- fastapi
|
||||
- frontend
|
||||
- php
|
||||
networks:
|
||||
- app-network
|
||||
restart: always
|
||||
|
||||
# --- MeiliSearch Service ---
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
container_name: meilisearch
|
||||
hostname: meilisearch
|
||||
# Не публикуем порт наружу, доступ только через FastAPI/Nginx
|
||||
# ports:
|
||||
# - "7700:7700"
|
||||
expose:
|
||||
- "7700" # Внутренний порт
|
||||
environment:
|
||||
# ВАЖНО: Используйте СВОЙ надежный мастер-ключ!
|
||||
- MEILI_MASTER_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
- MEILI_NO_ANALYTICS=true
|
||||
- MEILI_ENV=production
|
||||
volumes:
|
||||
- meili_data:/data.ms # Сохранение данных MeiliSearch
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: dressed-for-success-frontend
|
||||
hostname: frontend
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://0.0.0.0:8000/api
|
||||
- NEXT_PUBLIC_BASE_URL=http://0.0.0.0:8000
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- frontend
|
||||
dns_search: .
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: dressed-for-success-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
# - "443:443" # Раскомментируйте для HTTPS
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./backend/uploads:/app/uploads:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
restart: always
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: dressed-for-success-db
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: shop_db
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- postgres
|
||||
dns_search: .
|
||||
restart: always
|
||||
- app-network
|
||||
# Алиас не обязателен, если имя сервиса совпадает с hostname
|
||||
# aliases:
|
||||
# - meilisearch
|
||||
|
||||
# --- Сеть для взаимодействия контейнеров ---
|
||||
networks:
|
||||
app_network:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
# --- Тома для сохранения данных ---
|
||||
volumes:
|
||||
postgres_data:
|
||||
meili_data: # Для данных MeiliSearch
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
frontend_next: # Для кэша сборки Next.js (опционально)
|
||||
driver: local
|
||||
|
||||
|
||||
|
||||
# version: '3.8' # Compose Specification v3.8
|
||||
|
||||
# services:
|
||||
# fastapi:
|
||||
# build:
|
||||
# context: ./backend # директория с Dockerfile FastAPI
|
||||
# volumes:
|
||||
# - ./backend:/app # монтируем код для live-reload
|
||||
# command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # hot-reload
|
||||
# ports:
|
||||
# - "8000:8000"
|
||||
# networks:
|
||||
# - app-network
|
||||
|
||||
# php:
|
||||
# image: php:8.2-apache # официальный PHP Apache образ
|
||||
# volumes:
|
||||
# - ./php:/var/www/html
|
||||
# ports:
|
||||
# - "8081:80"
|
||||
# networks:
|
||||
# - app-network
|
||||
|
||||
# nginx:
|
||||
# image: nginx:stable
|
||||
# volumes:
|
||||
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # монтируем конфиг NGINX
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# depends_on:
|
||||
# - fastapi
|
||||
# - php
|
||||
# networks:
|
||||
# - app-network
|
||||
|
||||
# meilisearch:
|
||||
# image: getmeili/meilisearch:latest
|
||||
# container_name: dressed-for-success-meilisearch
|
||||
# hostname: meilisearch
|
||||
# ports:
|
||||
# - "7700:7700"
|
||||
# environment:
|
||||
# - MEILI_MASTER_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
# - MEILI_NO_ANALYTICS=true
|
||||
# - MEILI_ENV=production
|
||||
# volumes:
|
||||
# - meilisearch_data:/meili_data
|
||||
# healthcheck:
|
||||
# test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
# start_period: 15s
|
||||
# restart: always
|
||||
# networks:
|
||||
# app-network:
|
||||
# aliases:
|
||||
# - meilisearch
|
||||
# dns_search: .
|
||||
|
||||
# volumes:
|
||||
# meilisearch_data:
|
||||
|
||||
# networks:
|
||||
# app-network: # общая сеть для контейнеров
|
||||
# driver: bridge
|
||||
|
||||
BIN
frontend/.DS_Store
vendored
21
frontend/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
# Используем Node.js Alpine для сборки и запуска (один этап, без builder)
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json и package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Устанавливаем зависимости
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Копируем весь исходный код приложения
|
||||
COPY . .
|
||||
|
||||
# Собираем проект
|
||||
RUN npm run build
|
||||
|
||||
# Открываем порт для SSR сервера
|
||||
EXPOSE 3000
|
||||
|
||||
# Запуск в режиме production
|
||||
CMD ["npm", "run", "start"]
|
||||
BIN
frontend/app/(main)/.DS_Store
vendored
@ -4,371 +4,259 @@ import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { motion } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export default function AboutPage() {
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref4, inView4] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref5, inView5] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[60vh] min-h-[400px] md:min-h-[500px] lg:min-h-[600px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=1080&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-light mb-4 tracking-tight">О нашем бренде</h1>
|
||||
<p className="text-lg md:text-xl opacity-90 mb-6 font-light">Мы создаем одежду, которая становится частью вашей истории и отражает вашу индивидуальность</p>
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Качество
|
||||
</span>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Стиль
|
||||
</span>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Устойчивое развитие
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-white text-primary hover:bg-white/90 rounded-none mt-2"
|
||||
asChild
|
||||
>
|
||||
<Link href="#our-story">
|
||||
Узнать больше
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Story */}
|
||||
<section id="our-story" className="py-16 md:py-20 lg:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView1 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="grid md:grid-cols-2 gap-8 md:gap-12 items-center"
|
||||
>
|
||||
<div className="order-2 md:order-1">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наша история</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Путь к созданию уникального бренда</h2>
|
||||
<div className="space-y-4 text-gray-700">
|
||||
<p className="text-base md:text-lg">
|
||||
Наш бренд был основан в 2015 году с простой, но амбициозной целью: создавать одежду, которая сочетает
|
||||
в себе элегантность, комфорт и устойчивое развитие. Мы начали с небольшой коллекции базовых предметов
|
||||
гардероба, созданных из экологически чистых материалов.
|
||||
</p>
|
||||
<p className="text-base md:text-lg">
|
||||
С годами мы росли, но наши ценности оставались неизменными. Мы по-прежнему стремимся создавать одежду,
|
||||
которая не только выглядит стильно, но и производится с уважением к людям и планете.
|
||||
</p>
|
||||
<p className="text-base md:text-lg">
|
||||
Сегодня наш бренд представлен в нескольких странах, но мы по-прежнему сохраняем индивидуальный подход
|
||||
к каждому изделию, уделяя особое внимание деталям и качеству.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
asChild
|
||||
>
|
||||
<Link href="/collections">
|
||||
Смотреть коллекции
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative order-1 md:order-2">
|
||||
<div className="relative h-[300px] md:h-[400px] lg:h-[500px] overflow-hidden">
|
||||
<Image
|
||||
src="/placeholder.svg?height=1000&width=800"
|
||||
alt="История бренда"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 bg-tertiary p-4 md:p-6 max-w-[200px] md:max-w-xs">
|
||||
<p className="text-primary font-medium text-base md:text-lg">2015</p>
|
||||
<p className="text-gray-700 text-sm md:text-base">Год основания нашего бренда</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Values */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наши ценности</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Принципы, которыми мы руководствуемся</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
Наши ценности определяют все, что мы делаем — от выбора материалов до отношений с клиентами и партнерами.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
<main className="overflow-hidden">
|
||||
{/* Вступление */}
|
||||
<section className="relative py-28 bg-white overflow-hidden">
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
<div className="container mx-auto px-4 relative z-10 text-center">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.8, staggerChildren: 0.2 }}
|
||||
className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-6xl font-serif text-primary mb-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Качество превыше всего</h3>
|
||||
<p className="text-gray-700">
|
||||
Мы не идем на компромиссы, когда речь идет о качестве. Каждое изделие проходит строгий контроль, чтобы
|
||||
гарантировать долговечность и безупречный внешний вид.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Устойчивое развитие</h3>
|
||||
<p className="text-gray-700">
|
||||
Мы заботимся о планете и стремимся минимизировать наше воздействие на окружающую среду. Мы используем экологически чистые материалы и этичные методы производства.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"></line>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Клиентоориентированность</h3>
|
||||
<p className="text-gray-700">
|
||||
Наши клиенты — в центре всего, что мы делаем. Мы стремимся превзойти ожидания и создать положительный опыт на каждом этапе взаимодействия с нашим брендом.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
Наша Философия: Dressed for Success – Это Вы
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-3xl mx-auto text-lg md:text-xl text-gray-700 mb-8"
|
||||
>
|
||||
Dressed for Success – это не просто одежда, это философия и образ жизни. Бренд создает одежду для женщин, которые сочетают в себе все грани: силу и нежность, элегантность и практичность, уверенность и чувственность. Коллекции – это воплощение идеи, что каждая женщина заслуживает чувствовать себя особенной каждый день.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team Section */}
|
||||
<section className="py-16 md:py-20 lg:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наша команда</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Люди, создающие наш бренд</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
За каждым успешным брендом стоит команда талантливых и преданных своему делу людей. Познакомьтесь с некоторыми из них.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref3}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView3 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6"
|
||||
>
|
||||
{[
|
||||
{ name: "Анна Смирнова", role: "Основатель и креативный директор" },
|
||||
{ name: "Михаил Петров", role: "Директор по производству" },
|
||||
{ name: "Елена Иванова", role: "Главный дизайнер" },
|
||||
{ name: "Дмитрий Козлов", role: "Директор по маркетингу" }
|
||||
].map((member, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView3 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="relative overflow-hidden mb-4">
|
||||
<Image
|
||||
src={`/placeholder.svg?height=600&width=400&text=Team${index + 1}`}
|
||||
alt={member.name}
|
||||
width={400}
|
||||
height={600}
|
||||
className="w-full aspect-[3/4] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
|
||||
<div className="p-4 text-white">
|
||||
<p className="font-medium">{member.name}</p>
|
||||
<p className="text-sm text-white/80">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-primary font-medium">{member.name}</h3>
|
||||
<p className="text-gray-600 text-sm">{member.role}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Milestones */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наши достижения</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Ключевые моменты нашей истории</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
Путь нашего бренда отмечен важными вехами, которые сформировали нас такими, какие мы есть сегодня.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref4}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView4 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 h-full w-px bg-primary/20 hidden md:block"></div>
|
||||
|
||||
{[
|
||||
{ year: "2015", title: "Основание бренда", description: "Запуск первой коллекции базовых предметов гардероба." },
|
||||
{ year: "2017", title: "Открытие первого магазина", description: "Открытие нашего флагманского магазина в центре города." },
|
||||
{ year: "2019", title: "Международная экспансия", description: "Выход на международный рынок и запуск онлайн-магазина." },
|
||||
{ year: "2021", title: "Устойчивое развитие", description: "Переход на 100% экологически чистые материалы во всех коллекциях." },
|
||||
{ year: "2023", title: "Новая эра", description: "Запуск инновационной линейки одежды с использованием передовых технологий." }
|
||||
].map((milestone, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView4 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.2 }}
|
||||
className={`flex flex-col md:flex-row items-start md:items-center gap-4 mb-12 md:mb-16 relative ${
|
||||
index % 2 === 0 ? "md:flex-row-reverse text-left md:text-right" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="md:w-1/2 flex flex-col items-start md:items-center">
|
||||
<div className={`flex items-center gap-4 ${index % 2 === 0 ? "md:flex-row-reverse" : ""}`}>
|
||||
<div className="w-12 h-12 rounded-full bg-primary text-white flex items-center justify-center text-lg font-medium z-10">
|
||||
{milestone.year}
|
||||
</div>
|
||||
<div className={`h-px w-12 bg-primary/20 hidden md:block ${index % 2 === 0 ? "md:order-first" : ""}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`md:w-1/2 ${index % 2 === 0 ? "md:pr-16" : "md:pl-16"}`}>
|
||||
<h3 className="text-xl font-medium text-primary mb-2">{milestone.title}</h3>
|
||||
<p className="text-gray-700">{milestone.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-primary text-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
ref={ref5}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView5 ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-3xl mx-auto text-center"
|
||||
>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light mb-6">Станьте частью нашей истории</h2>
|
||||
<p className="text-lg md:text-xl opacity-90 mb-8 font-light">
|
||||
Присоединяйтесь к нам в нашем стремлении создавать одежду, которая не только выглядит прекрасно, но и производится с заботой о людях и планете.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
className="bg-white text-primary hover:bg-white/90 rounded-none min-w-[160px]"
|
||||
{/* Путь к Созданию */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Путь к Созданию</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Месяцы в дороге, мягчайшие ткани, сутки на производствах... За каждой вещью стоят сотни задач, в которые вкладывается особый интерес, трепет и предвкушение!
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Каждый материал тщательно отбирается, каждый этап производства контролируется, и каждой детали уделяется особое внимание, чтобы создать по-настоящему особенные вещи.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Смотреть каталог
|
||||
Смотреть коллекцию
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white/10 rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">
|
||||
Связаться с нами
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl">
|
||||
<Image
|
||||
src="/images/home/IMG_8382.jpeg"
|
||||
alt="Путь к созданию"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-32 h-32 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Наши Ценности */}
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-40 h-40 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-60 h-60 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-3xl md:text-4xl font-serif text-primary mb-12 text-center"
|
||||
>
|
||||
Наши Ценности
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-16">
|
||||
{/* Натуральные ткани */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Натуральные ткани</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Только лучшие натуральные материалы (тенсел, лён, вискоза), которые дарят комфорт и заботятся о самочувствии. В коллекциях используются ткани, которые приятны к телу и позволяют коже дышать.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Внимание к деталям */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Внимание к деталям</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Всё продумано до мелочей: от кроя до последней строчки и 17 пуговиц на блузах SHIK. Каждый элемент одежды создан с любовью и вниманием, чтобы подчеркнуть индивидуальность.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Малые тиражи */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Малые тиражи</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Уникальные вещи создаются небольшими партиями, чтобы каждая клиентка чувствовала себя особенной. Это позволяет контролировать качество каждого изделия и гарантировать, что каждая вещь создана с душой.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Комфорт и Стиль */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Комфорт и Стиль</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Баланс между элегантностью и практичностью. Одежда, в которой удобно и красиво жить. Философия бренда заключается в том, что стильная одежда должна быть комфортной, а комфортная одежда может быть стильной.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Наша Миссия */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative order-2 md:order-1"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl">
|
||||
<Image
|
||||
src="/images/home/IMG_8224.jpeg"
|
||||
alt="Наша миссия"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -left-6 w-32 h-32 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="order-1 md:order-2"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Наша Миссия</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Вдохновлять на победы, предлагая стильные, качественные и комфортные вещи, которые подчеркивают индивидуальность и помогают чувствовать себя уверенно.
|
||||
</p>
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-primary/10 mt-8 mb-8">
|
||||
<p className="italic text-gray-700 leading-relaxed">
|
||||
"В Dressed for Success создается не просто одежда, а настроение и уверенность. Каждая женщина заслуживает чувствовать себя особенной каждый день, и одежда бренда помогает ей в этом."
|
||||
</p>
|
||||
<p className="text-right mt-4 text-primary font-medium">— Команда Dressed for Success</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10">Посмотреть нашу первую коллекцию</span>
|
||||
<span className="absolute inset-0 bg-white scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100 z-0"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Подписка */}
|
||||
<section className="py-24 bg-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -right-20 -top-20 w-80 h-80 rounded-full border border-white/10 opacity-30"></div>
|
||||
<div className="absolute -left-40 bottom-0 w-96 h-96 rounded-full border border-white/10 opacity-20"></div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-3xl md:text-4xl font-serif text-white mb-6"
|
||||
>
|
||||
Следите за нами
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-white/80 mb-8 leading-relaxed"
|
||||
>
|
||||
Подпишитесь на наши соцсети, чтобы быть в курсе новых коллекций, специальных предложений и вдохновляющего контента.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex justify-center gap-10 mt-12"
|
||||
>
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold">
|
||||
Instagram
|
||||
</Link>
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold">
|
||||
Telegram
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react"
|
||||
@ -12,9 +12,40 @@ import { useInView } from "react-intersection-observer"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { CartItem as CartItemType } from "@/types/cart"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
import { normalizeProductImage } from "@/lib/catalog" // Импортируем нормализацию
|
||||
import React from "react" // Добавляем импорт React для React.memo
|
||||
|
||||
// Компонент для анимации изменения чисел
|
||||
const AnimatedNumber = ({ value, className }: { value: number, className?: string }) => {
|
||||
return (
|
||||
<motion.span
|
||||
key={value}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={className}
|
||||
>
|
||||
{value}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для анимации изменения цены
|
||||
const AnimatedPrice = ({ price, className }: { price: number, className?: string }) => {
|
||||
return (
|
||||
<motion.span
|
||||
key={price}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={className}
|
||||
>
|
||||
{formatPrice(price)}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
// Убираем компонент CartItemImage
|
||||
|
||||
interface RecommendedProduct {
|
||||
id: number
|
||||
@ -26,11 +57,22 @@ interface RecommendedProduct {
|
||||
|
||||
export default function CartPage() {
|
||||
const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart()
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
// Убираем useInView для ref1 и ref2, так как анимация будет управляться isFirstRender
|
||||
// const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
// const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) // Оставляем для будущих секций, если нужно
|
||||
const router = useRouter()
|
||||
const [processing, setProcessing] = useState<{ [key: number]: boolean }>({})
|
||||
|
||||
// Флаг для отслеживания первого рендера
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Сбрасываем флаг первого рендера после монтирования
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isFirstRender.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
|
||||
if (processing[itemId]) return
|
||||
@ -59,7 +101,7 @@ export default function CartPage() {
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.total_price
|
||||
const subtotal = cart.total_amount ?? 0
|
||||
const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000
|
||||
const total = subtotal + shipping
|
||||
|
||||
@ -126,18 +168,19 @@ export default function CartPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
|
||||
{/* Cart Items */}
|
||||
{/* Убираем ref1 и меняем логику animate */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={inView1 ? "visible" : "hidden"}
|
||||
initial={isFirstRender.current ? "hidden" : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? "visible" : { opacity: 1 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { delay: 0.1 } : { duration: 0 }} // Небольшая задержка для контейнера при первом рендере
|
||||
className="md:col-span-2 space-y-6 md:space-y-8"
|
||||
>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items.length})</h2>
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items_count})</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -152,22 +195,28 @@ export default function CartPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{cart.items.map((item) => (
|
||||
{cart.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
variants={itemVariants}
|
||||
initial={isFirstRender.current ? "hidden" : false}
|
||||
animate={isFirstRender.current ? "visible" : false}
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: index * 0.05 } : { duration: 0 }}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:items-center border-b pb-6 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{/* Изображение товара */}
|
||||
{/* Изображение товара через background-image */}
|
||||
<div className="relative w-24 h-32 sm:w-32 sm:h-40 flex-shrink-0 rounded-md overflow-hidden border border-gray-200">
|
||||
{item.image || item.product_image ? (
|
||||
<Link href={`/product/${item.productId || item.product_id}`}>
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover bg-secondary/10"
|
||||
/>
|
||||
{item.product_image ? (
|
||||
<Link href={`/product/${item.product_id}`}>
|
||||
<div
|
||||
className="w-full h-full bg-secondary/10 lazy-bg"
|
||||
data-bg={`url(${normalizeProductImage(item.product_image)})`}
|
||||
style={{
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
aria-label={item.product_name || "Товар"}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
@ -179,21 +228,19 @@ export default function CartPage() {
|
||||
{/* Информация о товаре */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<Link
|
||||
href={`/product/${item.productId || item.product_id}`}
|
||||
href={`/product/${item.product_id}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors duration-200"
|
||||
>
|
||||
<h3 className="font-medium text-base sm:text-lg line-clamp-2">
|
||||
{item.name || item.product_name || "Товар"}
|
||||
{item.product_name || "Товар"}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Вариант товара - размер и цвет */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{(item.size || item.variant_name) && (
|
||||
<span>Размер: {item.size || item.variant_name}</span>
|
||||
{item.variant_name && (
|
||||
<span>Размер: {item.variant_name}</span>
|
||||
)}
|
||||
{(item.size || item.variant_name) && item.color && <span className="mx-2">•</span>}
|
||||
{item.color && <span>Цвет: {item.color}</span>}
|
||||
</div>
|
||||
|
||||
{/* Количество и цена */}
|
||||
@ -213,7 +260,9 @@ export default function CartPage() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-center w-8 select-none">{item.quantity}</span>
|
||||
<span className="text-center w-8 select-none">
|
||||
<AnimatedNumber value={item.quantity} />
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -233,7 +282,9 @@ export default function CartPage() {
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Цена:</div>
|
||||
<div className="font-medium text-primary">{formatPrice(item.price * item.quantity)}</div>
|
||||
<div className="font-medium text-primary">
|
||||
<AnimatedPrice price={item.total_price} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@ -259,11 +310,11 @@ export default function CartPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary */}
|
||||
{/* Убираем ref2 и меняем логику анимации */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
initial={isFirstRender.current ? { opacity: 0, y: 20 } : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? { opacity: 1, y: 0 } : { opacity: 1, y: 0 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: 0.3 } : { duration: 0 }} // Анимация только при первом рендере
|
||||
className="md:sticky md:top-24 h-fit hidden md:block"
|
||||
>
|
||||
<div className="border border-gray-100 p-6 rounded-lg bg-white shadow-sm">
|
||||
@ -274,8 +325,10 @@ export default function CartPage() {
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">Товары ({cart.items_count}):</span>
|
||||
<span className="font-medium">
|
||||
<AnimatedPrice price={cart.total_amount} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@ -289,7 +342,9 @@ export default function CartPage() {
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<AnimatedPrice price={total} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -299,7 +354,7 @@ export default function CartPage() {
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
<Link href="/checkout" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@ -337,36 +392,11 @@ export default function CartPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary for mobile - visible only on small screens */}
|
||||
{/* Убираем кнопку-переключатель и делаем блок видимым по умолчанию */}
|
||||
<div className="block md:hidden">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mb-8 flex items-center justify-center"
|
||||
disabled={cart.items.length === 0}
|
||||
onClick={() => {
|
||||
const orderSummary = document.getElementById('mobile-order-summary');
|
||||
if (orderSummary) {
|
||||
orderSummary.classList.toggle('hidden');
|
||||
|
||||
// Меняем иконку и текст при раскрытии/скрытии
|
||||
const icon = document.getElementById('toggle-icon');
|
||||
if (icon) {
|
||||
icon.classList.toggle('rotate-270');
|
||||
icon.classList.toggle('rotate-90');
|
||||
}
|
||||
|
||||
const buttonText = document.getElementById('toggle-text');
|
||||
if (buttonText) {
|
||||
buttonText.innerText = orderSummary.classList.contains('hidden') ?
|
||||
'Посмотреть итог заказа' : 'Скрыть итог заказа';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span id="toggle-text">Посмотреть итог заказа</span>
|
||||
<span className="ml-2 font-medium">{formatPrice(total)}</span>
|
||||
<ChevronLeft id="toggle-icon" className="ml-auto h-5 w-5 transform rotate-90" />
|
||||
</Button>
|
||||
{/* Удалена кнопка-переключатель */}
|
||||
|
||||
<div id="mobile-order-summary" className="hidden bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100">
|
||||
<div id="mobile-order-summary" className="bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100"> {/* Убран класс 'hidden' */}
|
||||
<h2 className="text-xl font-medium text-primary mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Сводка заказа
|
||||
@ -374,8 +404,10 @@ export default function CartPage() {
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">Товары ({cart.items_count}):</span>
|
||||
<span className="font-medium">
|
||||
<AnimatedPrice price={cart.total_amount} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@ -389,7 +421,9 @@ export default function CartPage() {
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<AnimatedPrice price={total} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -398,7 +432,7 @@ export default function CartPage() {
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
<Link href="/checkout" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@ -414,4 +448,3 @@ export default function CartPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useState } from "react"
|
||||
import React, { Suspense, useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart } from "lucide-react"
|
||||
@ -12,11 +12,12 @@ import { ImageSlider } from "@/components/product/ImageSlider"
|
||||
import { ProductDetails as ProductDetailsComponent } from "@/components/product/ProductDetails"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { motion } from "framer-motion"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { toast } from "sonner"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
@ -25,6 +26,10 @@ interface ProductPageProps {
|
||||
}
|
||||
|
||||
export default function ProductPage({ params }: ProductPageProps) {
|
||||
// Используем React.use() для доступа к свойствам params
|
||||
const resolvedParams = React.use(params);
|
||||
const slug = resolvedParams.slug;
|
||||
|
||||
const [product, setProduct] = useState<ProductDetails | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
@ -32,18 +37,19 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
const [imageRef, imageInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [detailsRef, detailsInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const { addToCart, loading: cartLoading } = useCart()
|
||||
const { toggleItem, isInWishlist } = useWishlist();
|
||||
|
||||
// Загрузка товара при монтировании компонента
|
||||
useState(() => {
|
||||
// layout и хлебные крошки всегда видимы
|
||||
// основной контент fade-in после загрузки
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const productData = await catalogService.getProductBySlug(params.slug)
|
||||
|
||||
const productData = await catalogService.getProductBySlug(slug)
|
||||
if (!productData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
setProduct(productData)
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке товара:", err)
|
||||
@ -52,68 +58,42 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct()
|
||||
})
|
||||
|
||||
// Если все еще загружается, показываем скелетон
|
||||
if (loading) {
|
||||
return <ProductSkeleton />
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="container mx-auto py-20 px-4 text-center">
|
||||
<div className="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h1 className="text-2xl font-medium mb-4">Товар не найден</h1>
|
||||
<p className="text-gray-600 mb-6">К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="rounded-full bg-primary hover:bg-primary/90 text-white px-8 py-6">Вернуться в каталог</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<main className="bg-tertiary/5 min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
{/* Навигация */}
|
||||
<motion.div
|
||||
{/* Навигация и хлебные крошки */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<Link
|
||||
href="/catalog"
|
||||
<Link
|
||||
href="/catalog"
|
||||
className="flex items-center text-sm text-primary/70 hover:text-primary transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2 group-hover:transform group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Вернуться в каталог</span>
|
||||
</Link>
|
||||
|
||||
{/* Хлебные крошки */}
|
||||
<motion.nav
|
||||
<motion.nav
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex items-center text-sm text-neutral-500 overflow-x-auto whitespace-nowrap pb-1"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
Главная
|
||||
</Link>
|
||||
<Link href="/" className="hover:text-primary transition-colors">Главная</Link>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">
|
||||
Каталог
|
||||
</Link>
|
||||
{product.category_name && (
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">Каталог</Link>
|
||||
{product?.category_name && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/catalog?category_id=${product.category_id}`}
|
||||
<Link
|
||||
href={`/catalog?category_id=${product.category_id}`}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{product.category_name}
|
||||
@ -121,197 +101,198 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product?.name || ''}</span>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Основной контент товара */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<motion.div
|
||||
ref={imageRef}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={imageInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-1 md:order-none relative"
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="aspect-square rounded-3xl" />}>
|
||||
<div className="relative rounded-3xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */}
|
||||
{new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">
|
||||
Новинка
|
||||
</Badge>
|
||||
)}
|
||||
{product.discount_price && (
|
||||
<Badge variant="destructive" className="rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - product.discount_price / product.price) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления в избранное */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-10 h-10"
|
||||
onClick={() => toast.success(`${product.name} добавлен в избранное`)}
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
{/* Блок информации о товаре */}
|
||||
<motion.div
|
||||
ref={detailsRef}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={detailsInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-2 md:order-none"
|
||||
>
|
||||
<div className="bg-white rounded-3xl shadow-sm p-6 md:p-8">
|
||||
{/* Категория и название */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mb-6"
|
||||
>
|
||||
{product.category_name && (
|
||||
<div className="text-sm text-primary/60 mb-2 uppercase tracking-wider font-medium">
|
||||
{product.category_name}
|
||||
{/* Блок изображений и инфо */}
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div key="skeleton" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="col-span-2">
|
||||
<ProductSkeleton />
|
||||
</motion.div>
|
||||
) : error || !product ? (
|
||||
<motion.div key="error" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="col-span-2">
|
||||
<div className="container mx-auto py-20 px-4 text-center">
|
||||
<div className="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h1 className="text-2xl font-medium mb-4">Товар не найден</h1>
|
||||
<p className="text-gray-600 mb-6">К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="rounded-full bg-primary hover:bg-primary/90 text-white px-8 py-6">Вернуться в каталог</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-light mb-4 text-primary tracking-tight">{product.name}</h1>
|
||||
|
||||
{/* Цена */}
|
||||
<div className="flex items-center gap-3">
|
||||
{product.discount_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.discount_price)}
|
||||
</span>
|
||||
<span className="text-lg text-primary/50 line-through">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Компонент выбора размера и количества */}
|
||||
<ProductDetailsComponent product={product} />
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Описание товара */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Tabs defaultValue="description" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-tertiary/10 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Описание
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="care"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Уход
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-lg p-5 border border-tertiary/10"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
typeof product.description === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
) : (
|
||||
<p>Описание недоступно</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.care_instructions ? (
|
||||
typeof product.care_instructions === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.care_instructions }} />
|
||||
) : (
|
||||
<p>Инструкции по уходу недоступны</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Инструкции по уходу отсутствуют</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
|
||||
{/* Информация о доставке и возврате */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<Truck className="h-5 w-5" />
|
||||
) : (
|
||||
<motion.div key="content" initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 30 }} transition={{ duration: 0.5 }} className="col-span-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<div className="order-1 md:order-none relative">
|
||||
<div className="relative rounded-3xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{product && new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">Новинка</Badge>
|
||||
)}
|
||||
{product?.discount_price && (
|
||||
<Badge variant="destructive" className="rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - (product.discount_price! / product.price)) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Кнопка добавления в избранное */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
{product && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isInWishlist(product.id) ? "default" : "secondary"}
|
||||
className={`rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-10 h-10 transition-all ${isInWishlist(product.id) ? 'bg-primary/90 text-white' : ''}`}
|
||||
aria-pressed={isInWishlist(product.id)}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleItem({
|
||||
id: product.id,
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
discount_price: product.discount_price,
|
||||
image: product.primary_image || (product.images && product.images[0]?.image_url) || '',
|
||||
slug: product.slug,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${isInWishlist(product.id) ? 'fill-primary text-white' : ''}`} />
|
||||
<span className="sr-only">{isInWishlist(product.id) ? 'Убрать из избранного' : 'Добавить в избранное'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ImageSlider images={product?.images || []} productName={product?.name || ''} />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Доставка по всей России 1-3 рабочих дня
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
{/* Блок информации о товаре */}
|
||||
<div className="order-2 md:order-none">
|
||||
<div className="bg-white rounded-3xl shadow-sm p-6 md:p-8">
|
||||
{product?.category_name && (
|
||||
<div className="text-sm text-primary/60 mb-2 uppercase tracking-wider font-medium">
|
||||
{product.category_name}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl md:text-4xl font-light mb-4 text-primary tracking-tight">{product?.name}</h1>
|
||||
{/* Цена */}
|
||||
<div className="flex items-center gap-3">
|
||||
{product.discount_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.discount_price)}
|
||||
</span>
|
||||
<span className="text-lg text-primary/50 line-through">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
{/* Компонент выбора размера и количества */}
|
||||
<ProductDetailsComponent product={product} />
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
{/* Описание товара */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Tabs defaultValue="description" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-tertiary/10 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Описание
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="care"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Уход
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-lg p-5 border border-tertiary/10"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
typeof product.description === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
) : (
|
||||
<p>Описание недоступно</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.care_instructions ? (
|
||||
typeof product.care_instructions === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.care_instructions }} />
|
||||
) : (
|
||||
<p>Инструкции по уходу недоступны</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Инструкции по уходу отсутствуют</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
{/* Информация о доставке и возврате */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<Truck className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Доставка по всей России 1-3 рабочих дня
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Бесплатный возврат в течение 14 дней
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Бесплатный возврат в течение 14 дней
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@ -326,12 +307,12 @@ function ProductSkeleton() {
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-5 w-64 sm:w-96" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div className="order-1 md:order-none">
|
||||
<Skeleton className="aspect-square w-full rounded-3xl shadow-md" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="order-2 md:order-none">
|
||||
<Skeleton className="rounded-3xl p-6 space-y-6 h-full bg-white/50">
|
||||
<div className="space-y-4">
|
||||
@ -339,9 +320,9 @@ function ProductSkeleton() {
|
||||
<Skeleton className="h-12 w-full sm:w-4/5" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@ -352,9 +333,9 @@ function ProductSkeleton() {
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
<Skeleton className="h-12 w-full rounded-full" />
|
||||
</div>
|
||||
|
||||
|
||||
<Separator className="bg-primary/10" />
|
||||
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-1/2 rounded-lg" />
|
||||
@ -362,7 +343,7 @@ function ProductSkeleton() {
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full rounded-lg border border-neutral-200" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
@ -373,4 +354,4 @@ function ProductSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useMemo } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@ -25,6 +25,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import catalogService, { Product, Category, Collection, Size } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useCatalogData, ProductQueryParams } from "@/hooks/useCatalogData"
|
||||
import { useDebounce } from "@/hooks/useDebounce"
|
||||
|
||||
// Расширение интерфейса Product для поддержки дополнительных свойств
|
||||
interface ExtendedProduct extends Product {
|
||||
@ -50,25 +52,34 @@ interface ProductsResponse {
|
||||
|
||||
export default function CatalogPage({ searchParams }: { searchParams?: { [key: string]: string } }) {
|
||||
const searchParamsObject = useSearchParams();
|
||||
|
||||
|
||||
// Используем наш новый хук для работы с данными каталога
|
||||
const catalogData = useCatalogData();
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
// Применяем дебаунсинг к поисковому запросу
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const [sortOption, setSortOption] = useState("popular")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
// Состояния для реальных данных
|
||||
|
||||
// Состояния для отображения продуктов
|
||||
const [products, setProducts] = useState<ExtendedProduct[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [sizes, setSizes] = useState<Size[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
const [selectedCollection, setSelectedCollection] = useState<number | null>(null)
|
||||
const [selectedSizes, setSelectedSizes] = useState<number[]>([])
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
|
||||
const [productsError, setProductsError] = useState<string | null>(null)
|
||||
|
||||
// Сохраняем последние успешные продукты для плавного UX
|
||||
const [lastProducts, setLastProducts] = useState<ExtendedProduct[]>([])
|
||||
|
||||
// Получаем данные из хука
|
||||
const { categories, collections, sizes, loading, error } = catalogData;
|
||||
|
||||
// Инициализация фильтров из параметров URL
|
||||
useEffect(() => {
|
||||
@ -77,25 +88,25 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
if (categoryId) {
|
||||
setSelectedCategory(Number(categoryId));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие коллекции в URL
|
||||
const collectionId = searchParamsObject.get('collection_id');
|
||||
if (collectionId) {
|
||||
setSelectedCollection(Number(collectionId));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие размеров в URL
|
||||
const sizeIds = searchParamsObject.get('size_ids');
|
||||
if (sizeIds) {
|
||||
setSelectedSizes(sizeIds.split(',').map(id => Number(id)));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие поискового запроса
|
||||
const search = searchParamsObject.get('search');
|
||||
if (search) {
|
||||
setSearchQuery(search);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем сортировку
|
||||
const sort = searchParamsObject.get('sort');
|
||||
if (sort) {
|
||||
@ -108,183 +119,106 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
|
||||
checkIsMobile()
|
||||
window.addEventListener('resize', checkIsMobile)
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIsMobile)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Загрузка категорий
|
||||
// Загрузка начальных данных каталога при монтировании компонента
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
console.log('Загрузка категорий...');
|
||||
const categoriesData = await catalogService.getCategoriesTree();
|
||||
|
||||
if (categoriesData && categoriesData.length > 0) {
|
||||
setCategories(categoriesData);
|
||||
} else {
|
||||
setError("Не удалось загрузить категории с сервера");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке категорий:", err);
|
||||
setError("Не удалось загрузить категории с сервера");
|
||||
}
|
||||
// Загружаем категории, коллекции и размеры при первой загрузке страницы
|
||||
const loadInitialData = async () => {
|
||||
await Promise.all([
|
||||
catalogData.fetchCategories(),
|
||||
catalogData.fetchCollections(),
|
||||
catalogData.fetchSizes()
|
||||
]);
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
// Загрузка коллекций
|
||||
useEffect(() => {
|
||||
const loadCollections = async () => {
|
||||
try {
|
||||
console.log('Загрузка коллекций...');
|
||||
const collectionsResponse = await catalogService.getCollections();
|
||||
|
||||
if (collectionsResponse && collectionsResponse.collections) {
|
||||
setCollections(collectionsResponse.collections);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке коллекций:", err);
|
||||
}
|
||||
|
||||
// Подготавливаем параметры запроса
|
||||
const queryParams = useMemo((): ProductQueryParams => {
|
||||
const params: ProductQueryParams = {
|
||||
limit: 12,
|
||||
skip: (currentPage - 1) * 12,
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
loadCollections();
|
||||
}, []);
|
||||
|
||||
// Загрузка размеров
|
||||
useEffect(() => {
|
||||
const loadSizes = async () => {
|
||||
try {
|
||||
console.log('Загрузка размеров...');
|
||||
const sizesData = await catalogService.getSizes();
|
||||
|
||||
if (sizesData && sizesData.length > 0) {
|
||||
setSizes(sizesData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке размеров:", err);
|
||||
}
|
||||
};
|
||||
if (selectedCategory) {
|
||||
params.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
loadSizes();
|
||||
}, []);
|
||||
if (selectedCollection) {
|
||||
params.collection_id = selectedCollection;
|
||||
}
|
||||
|
||||
// Загрузка продуктов с учетом фильтров
|
||||
if (debouncedSearchQuery) {
|
||||
params.search = debouncedSearchQuery;
|
||||
}
|
||||
|
||||
// Добавляем сортировку
|
||||
if (sortOption) {
|
||||
params.sort = sortOption;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по размерам, если они выбраны
|
||||
if (selectedSizes.length > 0) {
|
||||
params.size_ids = selectedSizes;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [currentPage, selectedCategory, selectedCollection, debouncedSearchQuery, sortOption, selectedSizes]); // Добавляем selectedSizes в зависимости
|
||||
|
||||
// Загрузка продуктов при изменении параметров запроса
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('Загрузка продуктов...');
|
||||
|
||||
// Формируем параметры запроса
|
||||
const params: any = {
|
||||
limit: 12,
|
||||
skip: (currentPage - 1) * 12,
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
// Добавляем фильтр по категории
|
||||
if (selectedCategory) {
|
||||
params.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по коллекции
|
||||
if (selectedCollection) {
|
||||
params.collection_id = selectedCollection;
|
||||
}
|
||||
|
||||
// Добавляем поисковый запрос
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
|
||||
// Здесь можно добавить фильтрацию по размерам на стороне клиента,
|
||||
// так как API не поддерживает прямую фильтрацию по размерам
|
||||
|
||||
console.log('Параметры запроса:', params);
|
||||
|
||||
// Получаем продукты через сервис
|
||||
const response = await catalogService.getProducts(params) as ProductsResponse;
|
||||
console.log('Полученный ответ:', response);
|
||||
|
||||
setIsLoadingProducts(true);
|
||||
setProductsError(null);
|
||||
// Получаем продукты через кэширующий сервис
|
||||
const response = await catalogData.fetchProducts(queryParams);
|
||||
if (!response || !response.products) {
|
||||
setError("Товары не найдены");
|
||||
setLoading(false);
|
||||
setProductsError("Товары не найдены");
|
||||
setProducts([]);
|
||||
setTotalProducts(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let productsData = response.products;
|
||||
setProducts(response.products as ExtendedProduct[]);
|
||||
setTotalProducts(response.total);
|
||||
|
||||
// Фильтрация по размерам на стороне клиента, если выбраны размеры
|
||||
if (selectedSizes.length > 0) {
|
||||
productsData = productsData.filter(product => {
|
||||
// Проверяем, есть ли у продукта варианты с выбранными размерами
|
||||
if (!product.variants) return false;
|
||||
|
||||
return product.variants.some(variant =>
|
||||
selectedSizes.includes(variant.size_id) && variant.is_active && variant.stock > 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Сортировка полученных продуктов
|
||||
let sortedProducts = [...productsData];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'price_asc':
|
||||
sortedProducts.sort((a, b) => a.price - b.price);
|
||||
break;
|
||||
case 'price_desc':
|
||||
sortedProducts.sort((a, b) => b.price - a.price);
|
||||
break;
|
||||
case 'newest':
|
||||
sortedProducts.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at || '').getTime();
|
||||
const dateB = new Date(b.created_at || '').getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
default: // popular - оставляем как есть
|
||||
break;
|
||||
}
|
||||
|
||||
setProducts(sortedProducts);
|
||||
setLoading(false);
|
||||
setLastProducts(response.products as ExtendedProduct[]); // сохраняем последние успешные
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке продуктов:", err);
|
||||
setError("Не удалось загрузить продукты");
|
||||
setLoading(false);
|
||||
setProductsError("Не удалось загрузить продукты");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, [currentPage, selectedCategory, selectedCollection, selectedSizes, searchQuery, sortOption]);
|
||||
}, [queryParams, catalogData.fetchProducts]);
|
||||
|
||||
// Обработчик выбора категории
|
||||
const handleCategorySelect = (categoryId: number) => {
|
||||
setSelectedCategory(categoryId === selectedCategory ? null : categoryId);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории
|
||||
};
|
||||
|
||||
|
||||
// Обработчик выбора коллекции
|
||||
const handleCollectionSelect = (collectionId: number) => {
|
||||
setSelectedCollection(collectionId === selectedCollection ? null : collectionId);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении коллекции
|
||||
};
|
||||
|
||||
|
||||
// Обработчик выбора размера
|
||||
const handleSizeSelect = (sizeId: number) => {
|
||||
setSelectedSizes(prev =>
|
||||
prev.includes(sizeId)
|
||||
? prev.filter(id => id !== sizeId)
|
||||
setSelectedSizes(prev =>
|
||||
prev.includes(sizeId)
|
||||
? prev.filter(id => id !== sizeId)
|
||||
: [...prev, sizeId]
|
||||
);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении размера
|
||||
@ -324,35 +258,55 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
Категории
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={selectedCategory === category.id}
|
||||
onCheckedChange={() => handleCategorySelect(category.id)}
|
||||
className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{loading.categories ? (
|
||||
// Скелетон для загрузки категорий
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))
|
||||
) : categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={selectedCategory === category.id}
|
||||
onCheckedChange={() => handleCategorySelect(category.id)}
|
||||
className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Категории не найдены</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
|
||||
{/* Фильтр по коллекциям */}
|
||||
{collections.length > 0 && (
|
||||
<Accordion type="single" collapsible defaultValue="collections">
|
||||
<AccordionItem value="collections" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Коллекции
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{collections.map((collection) => (
|
||||
<Accordion type="single" collapsible defaultValue="collections">
|
||||
<AccordionItem value="collections" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Коллекции
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{loading.collections ? (
|
||||
// Скелетон для загрузки коллекций
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))
|
||||
) : collections.length > 0 ? (
|
||||
collections.map((collection) => (
|
||||
<div key={collection.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`collection-${collection.id}`}
|
||||
@ -367,22 +321,32 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
{collection.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Коллекции не найдены</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Фильтр по размерам */}
|
||||
{sizes.length > 0 && (
|
||||
<Accordion type="single" collapsible defaultValue="sizes">
|
||||
<AccordionItem value="sizes" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Размеры
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sizes.map((size) => (
|
||||
<Accordion type="single" collapsible defaultValue="sizes">
|
||||
<AccordionItem value="sizes" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Размеры
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loading.sizes ? (
|
||||
// Скелетон для загрузки размеров
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
))
|
||||
) : sizes.length > 0 ? (
|
||||
sizes.map((size) => (
|
||||
<div key={size.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`size-${size.id}`}
|
||||
@ -394,22 +358,24 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
htmlFor={`size-${size.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{size.name} ({size.value})
|
||||
{size.name} {size.code ? `(${size.code})` : ''}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Размеры не найдены</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компонент активных фильтров
|
||||
const ActiveFilters = () => {
|
||||
const filters = [];
|
||||
|
||||
|
||||
if (selectedCategory) {
|
||||
const category = categories.find(c => c.id === selectedCategory);
|
||||
if (category) {
|
||||
@ -420,7 +386,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (selectedCollection) {
|
||||
const collection = collections.find(c => c.id === selectedCollection);
|
||||
if (collection) {
|
||||
@ -431,20 +397,20 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (selectedSizes.length > 0) {
|
||||
const sizeNames = selectedSizes
|
||||
.map(id => sizes.find(s => s.id === id)?.value)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
|
||||
filters.push({
|
||||
id: 'sizes',
|
||||
name: `Размеры: ${sizeNames}`,
|
||||
onRemove: () => setSelectedSizes([])
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (searchQuery) {
|
||||
filters.push({
|
||||
id: 'search',
|
||||
@ -452,9 +418,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
onRemove: () => setSearchQuery('')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (filters.length === 0) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 my-4">
|
||||
{filters.map(filter => (
|
||||
@ -468,7 +434,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
@ -483,8 +449,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
|
||||
// Компонент сетки товаров
|
||||
const ProductGrid = ({ products, loading }: { products: ExtendedProduct[], loading: boolean }) => {
|
||||
if (loading) {
|
||||
// Отображаем скелетон для загрузки
|
||||
const showProducts = loading && lastProducts.length > 0 ? lastProducts : products;
|
||||
|
||||
// Если идет загрузка (или это первый рендер), всегда показываем только скелетон
|
||||
if (loading || (products.length === 0 && lastProducts.length === 0)) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-10">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@ -500,17 +468,18 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
// Сообщение 'Товары не найдены' только если загрузка завершена и был хотя бы один успешный запрос
|
||||
if (!loading && products.length === 0 && lastProducts.length > 0) {
|
||||
return (
|
||||
<div className="text-center py-12 w-full">
|
||||
<div className="bg-primary/5 rounded-xl p-8 max-w-md mx-auto">
|
||||
<Search className="h-10 w-10 text-primary/60 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">Товары не найдены</h3>
|
||||
<p className="text-gray-600 mb-4">Попробуйте изменить параметры поиска или фильтры</p>
|
||||
<Button
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="outline"
|
||||
className="border-primary/20 hover:border-primary/80 text-primary hover:bg-primary/5"
|
||||
@ -519,19 +488,27 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Отображение товаров
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-10">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence>
|
||||
{showProducts.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<ProductCard product={product} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -542,10 +519,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold">Каталог</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{loading ? 'Загрузка...' : `${totalProducts} товаров`}
|
||||
{isLoadingProducts ? 'Загрузка...' : `${totalProducts} товаров`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Сортировка */}
|
||||
<Select
|
||||
@ -562,12 +539,12 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<SelectItem value="newest">По новизне</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
{/* Фильтр для мобильной версии */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:hidden border-primary/20 text-primary/90 rounded-xl"
|
||||
>
|
||||
@ -594,10 +571,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Отображение активных фильтров */}
|
||||
<ActiveFilters />
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Фильтры (сайдбар) - только на десктопе */}
|
||||
<div className="hidden md:block md:col-span-3">
|
||||
@ -605,33 +582,20 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<FilterSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Основной контент с товарами */}
|
||||
<div className="md:col-span-9">
|
||||
{loading ? (
|
||||
<ProductGrid products={products} loading={loading} />
|
||||
) : error ? (
|
||||
{productsError ? (
|
||||
<div className="text-center py-12 bg-white rounded-3xl p-8">
|
||||
<h2 className="text-xl font-medium mb-2">Ошибка при загрузке товаров</h2>
|
||||
<p className="text-gray-500 mb-4">{error}</p>
|
||||
<p className="text-gray-500 mb-4">{productsError}</p>
|
||||
<Button onClick={() => window.location.reload()} className="rounded-xl">
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-3xl p-8">
|
||||
<h2 className="text-xl font-medium mb-2">Товары не найдены</h2>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Попробуйте изменить параметры фильтрации или поискать что-то другое.
|
||||
</p>
|
||||
<Button onClick={clearAllFilters} className="rounded-xl">
|
||||
Сбросить все фильтры
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ProductGrid products={products} loading={loading} />
|
||||
|
||||
<ProductGrid products={products} loading={isLoadingProducts} />
|
||||
{/* Пагинация */}
|
||||
{totalProducts > 12 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
@ -644,7 +608,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<ChevronDown className="w-4 h-4 rotate-90 mr-2" />
|
||||
Пред.
|
||||
</button>
|
||||
|
||||
|
||||
<div className="hidden sm:flex">
|
||||
{Array.from({ length: Math.min(5, Math.ceil(totalProducts / 12)) }).map((_, i) => {
|
||||
// Логика для отображения страниц вокруг текущей
|
||||
@ -655,15 +619,15 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
pageToShow = Math.ceil(totalProducts / 12) - (5 - i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (pageToShow > 0 && pageToShow <= Math.ceil(totalProducts / 12)) {
|
||||
return (
|
||||
<button
|
||||
key={pageToShow}
|
||||
onClick={() => setCurrentPage(pageToShow)}
|
||||
className={`w-10 h-10 flex items-center justify-center text-sm ${
|
||||
currentPage === pageToShow
|
||||
? 'bg-primary text-white font-medium'
|
||||
currentPage === pageToShow
|
||||
? 'bg-primary text-white font-medium'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@ -674,11 +638,11 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center px-3 sm:hidden">
|
||||
<span className="text-sm font-medium">{currentPage} из {Math.ceil(totalProducts / 12)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
disabled={currentPage === Math.ceil(totalProducts / 12)}
|
||||
onClick={() => setCurrentPage(p => Math.min(Math.ceil(totalProducts / 12), p + 1))}
|
||||
|
||||
@ -14,16 +14,23 @@ import { formatPrice } from "@/lib/utils"
|
||||
export default function ContactPage() {
|
||||
const { cart, loading } = useCart()
|
||||
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setInitialLoading(false)
|
||||
}
|
||||
}, [loading])
|
||||
const [whatsappText, setWhatsappText] = useState("")
|
||||
|
||||
// Формируем текст для WhatsApp при изменении корзины
|
||||
useEffect(() => {
|
||||
if (!loading && cart.items && cart.items.length > 0) {
|
||||
const itemsList = cart.items.map(item =>
|
||||
`- ${item.product_name || item.name} (${item.size || 'No size'}) x ${item.quantity} - ${formatPrice(item.price * item.quantity)}`
|
||||
`- ${item.product_name} (${item.variant_name || 'No size'}) x ${item.quantity} - ${formatPrice(item.total_price)}`
|
||||
).join('\n');
|
||||
|
||||
const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_price)}`;
|
||||
const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_amount)}`;
|
||||
|
||||
setWhatsappText(encodeURIComponent(message));
|
||||
}
|
||||
@ -89,10 +96,10 @@ export default function ContactPage() {
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center border-b pb-4">
|
||||
<div className="relative w-16 h-20 flex-shrink-0 rounded-md overflow-hidden bg-gray-100 mr-4">
|
||||
{item.image || item.product_image ? (
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
src={require('@/lib/catalog').normalizeProductImage(item.product_image || "")}
|
||||
alt={item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
@ -103,11 +110,14 @@ export default function ContactPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{item.product_name || item.name}</h3>
|
||||
<h3 className="text-sm font-medium">{item.product_name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Размер: {item.size || 'Не указан'} | Количество: {item.quantity}
|
||||
Размер: {item.variant_name || 'Не указан'} | Количество: {item.quantity}
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1">{formatPrice(item.price * item.quantity)}</p>
|
||||
{/* <p className="text-xs text-gray-500">
|
||||
Артикул: {item.slug || 'Не указан'}
|
||||
</p> */}
|
||||
<p className="text-sm font-medium mt-1">{formatPrice(item.total_price)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -117,12 +127,41 @@ export default function ContactPage() {
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">
|
||||
Товары (
|
||||
<motion.span
|
||||
key={cart.items_count}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{cart.items_count}
|
||||
</motion.span>
|
||||
):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
<motion.span
|
||||
key={cart.total_amount}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{formatPrice(cart.total_amount)}
|
||||
</motion.span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<motion.span
|
||||
key={cart.total_amount}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{formatPrice(cart.total_amount)}
|
||||
</motion.span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -175,4 +214,4 @@ export default function ContactPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import OrderSummary from "@/components/checkout/order-summary"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import OrderComment from "@/components/checkout/order-comment"
|
||||
import { orderService } from "@/lib/order-service"
|
||||
import { OrderCreate, Address } from "@/types/order"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
@ -24,10 +25,10 @@ export default function CheckoutPage() {
|
||||
const { cart, loading, clearCart } = useCart()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
// Состояния для обработки заказа
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
|
||||
// Состояния для форм и выбранных опций
|
||||
const [userInfo, setUserInfo] = useState({
|
||||
firstName: "",
|
||||
@ -35,47 +36,82 @@ export default function CheckoutPage() {
|
||||
email: "",
|
||||
phone: ""
|
||||
})
|
||||
|
||||
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("cdek")
|
||||
|
||||
const [address, setAddress] = useState({
|
||||
|
||||
const [address, setAddress] = useState<Address>({
|
||||
city: "Новокузнецк", // По умолчанию
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: ""
|
||||
})
|
||||
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("sbp")
|
||||
|
||||
|
||||
const [orderComment, setOrderComment] = useState("")
|
||||
|
||||
|
||||
// Собираем массив goods для СДЭК (TODO: заменить на реальные размеры/вес из товара)
|
||||
const goods = cart.items.map(item => ({
|
||||
length: 20, // TODO: заменить на реальные значения
|
||||
width: 20,
|
||||
height: 20,
|
||||
weight: 2, // кг
|
||||
}));
|
||||
|
||||
// Проверка валидности заполнения формы
|
||||
const isFormValid = () => {
|
||||
const isUserInfoValid =
|
||||
userInfo.firstName &&
|
||||
userInfo.email &&
|
||||
// Проверка информации о пользователе
|
||||
const isUserInfoValid =
|
||||
userInfo.firstName &&
|
||||
userInfo.email &&
|
||||
userInfo.phone
|
||||
|
||||
const isAddressValid =
|
||||
address.city &&
|
||||
address.street &&
|
||||
address.house
|
||||
|
||||
return isUserInfoValid && isAddressValid
|
||||
|
||||
// Проверка адреса в зависимости от способа доставки
|
||||
let isAddressValid = false;
|
||||
|
||||
if (deliveryMethod === "cdek") {
|
||||
// Для CDEK нужен выбранный пункт выдачи
|
||||
isAddressValid = !!address.city && !!address.cdekPvz;
|
||||
|
||||
// Добавляем логирование для отладки
|
||||
console.log("Проверка валидности формы для CDEK:", {
|
||||
city: address.city,
|
||||
cdekPvz: !!address.cdekPvz,
|
||||
isAddressValid
|
||||
});
|
||||
} else {
|
||||
// Для курьерской доставки нужен адрес
|
||||
isAddressValid =
|
||||
!!address.city &&
|
||||
!!address.street &&
|
||||
!!address.house;
|
||||
}
|
||||
|
||||
return isUserInfoValid && isAddressValid;
|
||||
}
|
||||
|
||||
|
||||
// Обработчик оформления заказа
|
||||
const handleSubmitOrder = async () => {
|
||||
// Проверка валидности формы
|
||||
if (!isFormValid()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Форма не заполнена",
|
||||
description: "Пожалуйста, заполните все обязательные поля"
|
||||
})
|
||||
// Проверяем, что именно не заполнено
|
||||
if (deliveryMethod === "cdek" && !address.cdekPvz) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Не выбран пункт выдачи СДЭК",
|
||||
description: "Пожалуйста, нажмите кнопку 'Выбрать пункт выдачи' и выберите пункт выдачи СДЭК"
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Форма не заполнена",
|
||||
description: "Пожалуйста, заполните все обязательные поля"
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@ -84,12 +120,12 @@ export default function CheckoutPage() {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
|
||||
// Подготавливаем данные для заказа
|
||||
const orderData = {
|
||||
const orderData: OrderCreate = {
|
||||
userInfo,
|
||||
items: cart.items,
|
||||
address,
|
||||
@ -97,7 +133,10 @@ export default function CheckoutPage() {
|
||||
paymentMethod,
|
||||
comment: orderComment
|
||||
}
|
||||
|
||||
|
||||
// Вызываем отладочную функцию для анализа структуры заказа
|
||||
orderService.debugOrderStructure(orderData)
|
||||
|
||||
// Сохраняем состояние корзины для показа на странице успешного оформления
|
||||
try {
|
||||
// Создаем копию корзины
|
||||
@ -106,13 +145,13 @@ export default function CheckoutPage() {
|
||||
} catch (error) {
|
||||
console.error("Ошибка при сохранении состояния корзины:", error);
|
||||
}
|
||||
|
||||
|
||||
// Создаем заказ
|
||||
const order = await orderService.createOrder(orderData)
|
||||
|
||||
|
||||
// Очищаем корзину
|
||||
await clearCart()
|
||||
|
||||
|
||||
// Перенаправляем на страницу успешного оформления
|
||||
router.push(`/checkout/success?order_id=${order.orderId}&total=${order.total}&email=${encodeURIComponent(userInfo.email)}`)
|
||||
} catch (error) {
|
||||
@ -123,7 +162,7 @@ export default function CheckoutPage() {
|
||||
} catch (error) {
|
||||
console.error("Ошибка при очистке данных заказа:", error);
|
||||
}
|
||||
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Ошибка",
|
||||
@ -133,7 +172,7 @@ export default function CheckoutPage() {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Проверка наличия товаров в корзине
|
||||
if (!loading && (!cart.items || cart.items.length === 0)) {
|
||||
return (
|
||||
@ -141,7 +180,7 @@ export default function CheckoutPage() {
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm text-center">
|
||||
<p className="text-lg mb-6">Ваша корзина пуста</p>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => router.push("/catalog")}
|
||||
className="bg-black hover:bg-neutral-800"
|
||||
>
|
||||
@ -155,7 +194,7 @@ export default function CheckoutPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-12 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-8">
|
||||
{/* Левая колонка - формы и выбор опций */}
|
||||
<div className="md:col-span-8 space-y-6">
|
||||
@ -164,50 +203,51 @@ export default function CheckoutPage() {
|
||||
<h2 className="text-lg font-semibold mb-4">Информация о получателе</h2>
|
||||
<UserInfoForm userInfo={userInfo} setUserInfo={setUserInfo} />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Способ доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ доставки</h2>
|
||||
<DeliveryMethodSelector
|
||||
selected={deliveryMethod}
|
||||
onSelect={setDeliveryMethod}
|
||||
<DeliveryMethodSelector
|
||||
selected={deliveryMethod}
|
||||
onSelect={setDeliveryMethod}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Адрес доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Адрес доставки</h2>
|
||||
<AddressForm
|
||||
address={address}
|
||||
setAddress={setAddress}
|
||||
deliveryMethod={deliveryMethod}
|
||||
<AddressForm
|
||||
address={address}
|
||||
setAddress={setAddress}
|
||||
deliveryMethod={deliveryMethod}
|
||||
goods={goods} // goods для СДЭК
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Комментарий к заказу */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Комментарий к заказу</h2>
|
||||
<OrderComment comment={orderComment} setComment={setOrderComment} />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Способ оплаты */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ оплаты</h2>
|
||||
<PaymentMethodSelector
|
||||
selected={paymentMethod}
|
||||
onSelect={setPaymentMethod}
|
||||
<PaymentMethodSelector
|
||||
selected={paymentMethod}
|
||||
onSelect={setPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Правая колонка - сводка заказа */}
|
||||
<div className="md:col-span-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm sticky top-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Ваш заказ</h2>
|
||||
<OrderSummary cart={cart} />
|
||||
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
|
||||
<Button
|
||||
className="w-full bg-black hover:bg-neutral-800 mt-4"
|
||||
onClick={handleSubmitOrder}
|
||||
@ -222,7 +262,7 @@ export default function CheckoutPage() {
|
||||
"Оформить заказ"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<p className="text-sm text-gray-500 mt-4 text-center">
|
||||
Нажимая на кнопку, вы соглашаетесь с условиями обработки персональных данных и правилами магазина
|
||||
</p>
|
||||
|
||||
90
frontend/app/(main)/company-info/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function CompanyInfoPage() {
|
||||
return (
|
||||
<main className="overflow-hidden bg-white min-h-screen py-20 relative">
|
||||
{/* Декоративные круги */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-5xl font-serif text-primary mb-12 text-center"
|
||||
>
|
||||
Реквизиты компании
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-3xl mx-auto bg-white/80 backdrop-blur-sm rounded-3xl shadow-md p-6 md:p-10 space-y-6 border border-primary/10"
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">1. Наименование предприятия</h2>
|
||||
<p className="text-gray-700">Индивидуальный предприниматель Плотников Михаил Владимирович</p>
|
||||
<p className="text-gray-700">ИП Плотников Михаил Владимирович</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">2. ИНН</h2>
|
||||
<p className="text-gray-700">421713424512</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">3. Адрес</h2>
|
||||
<p className="text-gray-700">654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Грдины, дом 23, кв. 186</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">4. Телефон</h2>
|
||||
<p className="text-gray-700">8 905 966 2401</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">5. Эл. почта</h2>
|
||||
<p className="text-gray-700">Pmv-84@yandex.ru</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">6. Регистрация ИП</h2>
|
||||
<p className="text-gray-700">Межрайонная инспекция ФНС №15 по Кемеровской области-Кузбассу</p>
|
||||
<p className="text-gray-700">ОГРНИП 325420500025878 от 17.03.2025 г.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">7. Банк</h2>
|
||||
<p className="text-gray-700">Филиал «Новосибирский» АО «АЛЬФА-БАНК» г. Новосибирск</p>
|
||||
<p className="text-gray-700">БИК 045004774</p>
|
||||
<p className="text-gray-700">Корр. счет 30101810600000000774</p>
|
||||
<p className="text-gray-700">Расчетный счет 40802810023070009086</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">8. Руководитель</h2>
|
||||
<p className="text-gray-700">Плотников Михаил Владимирович</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.6 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<Link href="/" className="inline-flex items-center text-primary hover:text-primary/80 transition-colors font-medium">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
На главную
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,415 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { motion } from "framer-motion"
|
||||
import { Check, Mail, MapPin, Phone, ArrowRight, Send, Clock, ChevronRight } from "lucide-react"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formSubmitted, setFormSubmitted] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "general",
|
||||
message: "",
|
||||
})
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [mapRef, mapInView] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [faqRef, faqInView] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleRadioChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, subject: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Here you would normally send the form data to your backend
|
||||
console.log("Form submitted:", formData)
|
||||
setFormSubmitted(true)
|
||||
// Reset form after submission
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "general",
|
||||
message: "",
|
||||
})
|
||||
// Reset submission status after 5 seconds
|
||||
setTimeout(() => setFormSubmitted(false), 5000)
|
||||
}
|
||||
|
||||
// Анимационные варианты
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6 } }
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] md:min-h-[400px] lg:min-h-[500px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/50"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-serif mb-6 tracking-tight">СВЯЗАТЬСЯ С НАМИ</h1>
|
||||
<div className="w-20 h-[1px] bg-white/60 mb-6"></div>
|
||||
<p className="text-lg md:text-xl opacity-90 font-light">Мы всегда рады ответить на ваши вопросы и услышать ваше мнение</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid md:grid-cols-3 gap-8 md:gap-12 lg:gap-16">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={inView1 ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="md:col-span-1"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-serif text-primary mb-6 tracking-tight">КОНТАКТНАЯ ИНФОРМАЦИЯ</h2>
|
||||
<div className="w-16 h-[2px] bg-secondary mb-8"></div>
|
||||
|
||||
<div className="space-y-8 md:space-y-10">
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Адрес</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">ул. Ленина, 123, Москва, 123456</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">Россия</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Email</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">info@dressedforsuccessstore.com</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">support@dressedforsuccessstore.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<Phone className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Телефон</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">+7 (495) 123-45-67</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">Пн-Пт: 9:00 - 18:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 md:mt-12 p-6 md:p-8 bg-tertiary/10 border border-tertiary/20 rounded-sm">
|
||||
<div className="flex items-center mb-4">
|
||||
<Clock className="h-5 w-5 text-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-primary">Часы работы</h3>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm md:text-base">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Понедельник - Пятница:</span>
|
||||
<span className="text-gray-800 font-medium">10:00 - 20:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Суббота:</span>
|
||||
<span className="text-gray-800 font-medium">10:00 - 18:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Воскресенье:</span>
|
||||
<span className="text-gray-800 font-medium">11:00 - 17:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5 rounded-sm group"
|
||||
asChild
|
||||
>
|
||||
<a href="https://maps.google.com" target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||
Посмотреть на карте
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div className="bg-white p-6 md:p-8 lg:p-10 border border-gray-100 shadow-sm rounded-sm">
|
||||
<h2 className="text-xl md:text-2xl font-serif text-primary mb-6 tracking-tight">НАПИШИТЕ НАМ</h2>
|
||||
<div className="w-16 h-[2px] bg-secondary mb-8"></div>
|
||||
|
||||
{formSubmitted ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-green-50 border border-green-200 text-green-800 p-6 rounded-sm flex items-center"
|
||||
>
|
||||
<Check className="h-6 w-6 mr-3 text-green-600" />
|
||||
<div>
|
||||
<h3 className="font-medium">Сообщение отправлено!</h3>
|
||||
<p className="text-sm text-green-700">Спасибо за ваше сообщение. Мы свяжемся с вами в ближайшее время.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-gray-700">Имя</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-gray-700">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="ваш@email.com"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-gray-700">Телефон (опционально)</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-700">Тема обращения</Label>
|
||||
<RadioGroup
|
||||
value={formData.subject}
|
||||
onValueChange={handleRadioChange}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="general" id="general" className="text-primary" />
|
||||
<Label htmlFor="general" className="text-gray-700 cursor-pointer">Общий вопрос</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="order" id="order" className="text-primary" />
|
||||
<Label htmlFor="order" className="text-gray-700 cursor-pointer">Вопрос по заказу</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="product" id="product" className="text-primary" />
|
||||
<Label htmlFor="product" className="text-gray-700 cursor-pointer">Вопрос о товаре</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="partnership" id="partnership" className="text-primary" />
|
||||
<Label htmlFor="partnership" className="text-gray-700 cursor-pointer">Сотрудничество</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message" className="text-gray-700">Сообщение</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Ваше сообщение..."
|
||||
required
|
||||
className="min-h-[150px] border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-sm w-full md:w-auto group"
|
||||
>
|
||||
Отправить сообщение
|
||||
<Send className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Map Section */}
|
||||
<section
|
||||
ref={mapRef}
|
||||
className="py-16 md:py-24 bg-tertiary/10"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={mapInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl lg:text-3xl font-serif text-primary mb-4 tracking-tight">КАК НАС НАЙТИ</h2>
|
||||
<div className="w-20 h-[2px] bg-secondary mx-auto mb-6"></div>
|
||||
<p className="text-gray-600 text-sm md:text-base">
|
||||
Наш магазин расположен в центре города, в нескольких минутах ходьбы от станции метро.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={mapInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative h-[400px] md:h-[500px] bg-gray-200 rounded-sm overflow-hidden"
|
||||
>
|
||||
{/* Here you would normally embed a Google Map or similar */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-700 font-medium">Карта будет здесь</p>
|
||||
<p className="text-gray-500 text-sm">ул. Ленина, 123, Москва</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section
|
||||
ref={faqRef}
|
||||
className="py-16 md:py-24"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={faqInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl lg:text-3xl font-serif text-primary mb-4 tracking-tight">ЧАСТО ЗАДАВАЕМЫЕ ВОПРОСЫ</h2>
|
||||
<div className="w-20 h-[2px] bg-secondary mx-auto mb-6"></div>
|
||||
<p className="text-gray-600 text-sm md:text-base">
|
||||
Ответы на наиболее распространенные вопросы о нашем магазине и услугах
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={faqInView ? "visible" : "hidden"}
|
||||
variants={staggerContainer}
|
||||
className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto"
|
||||
>
|
||||
{[
|
||||
{
|
||||
question: "Как долго осуществляется доставка?",
|
||||
answer: "Стандартная доставка занимает 3-5 рабочих дней. Экспресс-доставка доступна в большинстве крупных городов и занимает 1-2 рабочих дня."
|
||||
},
|
||||
{
|
||||
question: "Какова политика возврата?",
|
||||
answer: "Вы можете вернуть товар в течение 14 дней с момента получения, если он не был в использовании и сохранил все бирки и упаковку."
|
||||
},
|
||||
{
|
||||
question: "Есть ли у вас программа лояльности?",
|
||||
answer: "Да, у нас есть программа лояльности, которая позволяет накапливать баллы за покупки и обменивать их на скидки."
|
||||
},
|
||||
{
|
||||
question: "Как я могу отследить свой заказ?",
|
||||
answer: "После отправки заказа вы получите электронное письмо с номером для отслеживания. Вы также можете проверить статус заказа в личном кабинете."
|
||||
}
|
||||
].map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={fadeIn}
|
||||
className="border border-gray-100 p-6 md:p-8 hover:border-primary/20 transition-colors rounded-sm hover:shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-primary mb-3">{faq.question}</h3>
|
||||
<p className="text-gray-600">{faq.answer}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={faqInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5 rounded-sm group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/faq" className="flex items-center">
|
||||
Все вопросы и ответы
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
frontend/app/(main)/contacts/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { MessageCircle, ArrowRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function ContactsPage() {
|
||||
|
||||
return (
|
||||
<main className="overflow-hidden bg-white min-h-screen relative">
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-primary">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 to-transparent z-10" />
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
initial={{ scale: 1.1, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1, transition: { duration: 1.5 } }}
|
||||
>
|
||||
<img
|
||||
src="/images/hero/image-3.jpeg"
|
||||
alt="Контактная информация"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10 text-white">
|
||||
<div className="max-w-3xl">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-serif mb-6"
|
||||
>
|
||||
КОНТАКТНАЯ ИНФОРМАЦИЯ
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.5 }}
|
||||
className="text-lg md:text-xl mb-8 font-light"
|
||||
>
|
||||
Мы ценим каждого клиента и всегда рады ответить на ваши вопросы.
|
||||
Свяжитесь с нами любым удобным способом, и мы поможем вам с выбором одежды,
|
||||
оформлением заказа или предоставим необходимую информацию.
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-start">
|
||||
{/* Левая колонка - Контактная информация */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Телефон</h2>
|
||||
<p className="text-xl text-gray-700 mb-2">+7 905 967 7125</p>
|
||||
{/* <p className="text-gray-600">Ежедневно с 10:00 до 20:00</p> */}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">E-MAIL</h2>
|
||||
<p className="text-xl text-gray-700">Pmv-84@yandex.ru</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Адрес</h2>
|
||||
<p className="text-xl text-gray-700 mb-2">654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Дружбы, дом 33</p>
|
||||
{/* <p className="text-gray-600">Время работы: 10:00 - 20:00</p> */}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Месседжеры и соцсети</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6"><rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/></svg>
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
<a href="https://t.me/" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
<span>Telegram</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Реквизиты организации</h2>
|
||||
<ul className="space-y-2 text-gray-700">
|
||||
<li><span className="font-medium">ИП Плотников Михаил Владимирович</span></li>
|
||||
<li><span className="font-medium">ОГРНИП:</span> 325420500025878 от 17.03.2025 г.</li>
|
||||
<li><span className="font-medium">ИНН:</span> 421713424512</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Правая колонка - Связь через WhatsApp */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-10 text-center">Напишите нам в WhatsApp</h2>
|
||||
|
||||
<div className="mb-8">
|
||||
<img src="/images/whatsapp-code.svg" alt="WhatsApp QR код" className="w-48 h-48 mx-auto" />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-8 text-center">
|
||||
Отсканируйте QR-код или нажмите на кнопку ниже, чтобы связаться с нами через WhatsApp.
|
||||
Мы ответим на все ваши вопросы в кратчайшие сроки.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-[#25D366] text-white hover:bg-[#128C7E] py-6 px-8 flex items-center gap-2 text-lg"
|
||||
>
|
||||
<a href="https://wa.me/79059677125" target="_blank" rel="noopener noreferrer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2"><path d="M3 21l1.65-3.8a9 9 0 1 1 3.4 2.9L3 21"/><path d="M9 10a.5.5 0 0 0 1 0V9a.5.5 0 0 0-1 0v1Zm0 0a5 5 0 0 0 5 5"/><path d="M9.5 11a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-1 0v1Zm0 0a3 3 0 0 0 3 3"/></svg>
|
||||
Написать в WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Карта или дополнительная информация */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Мы всегда на связи</h2>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Задавайте вопросы, предлагайте идеи, делитесь впечатлениями. Мы ценим обратную связь и стремимся стать лучше для вас.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex justify-center gap-6 mt-8"
|
||||
>
|
||||
<Button asChild className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden px-8 py-6 text-lg">
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10 flex items-center">ПЕРЕЙТИ В КАТАЛОГ <ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" /></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,333 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Search, Truck, CreditCard, RotateCcw, ShoppingBag, Mail, ArrowRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { motion } from "framer-motion"
|
||||
import { Search, Plus, Minus, ArrowRight, Mail } from "lucide-react"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function FAQPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [openCategories, setOpenCategories] = useState<string[]>(["shopping"])
|
||||
const [openQuestions, setOpenQuestions] = useState<string[]>([])
|
||||
const [activeCategory, setActiveCategory] = useState("shopping")
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
|
||||
// FAQ data structure
|
||||
const faqCategories = [
|
||||
{
|
||||
id: "shopping",
|
||||
title: "Покупки и заказы",
|
||||
icon: "💳",
|
||||
icon: <ShoppingBag className="h-5 w-5" />,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Как оформить заказ на сайте?",
|
||||
answer:
|
||||
"Чтобы оформить заказ, выберите понравившиеся товары, добавьте их в корзину, перейдите в корзину, нажмите кнопку 'Оформить заказ' и следуйте инструкциям. Вам потребуется указать контактную информацию, адрес доставки и выбрать способ оплаты.",
|
||||
answer: "Чтобы оформить заказ, выберите понравившиеся товары, добавьте их в корзину, перейдите в корзину, нажмите кнопку 'Оформить заказ' и следуйте инструкциям. Вам потребуется указать контактную информацию, адрес доставки и выбрать способ оплаты."
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "Какие способы оплаты доступны?",
|
||||
answer:
|
||||
"Мы принимаем различные способы оплаты: банковские карты (Visa, MasterCard, МИР), онлайн-платежи через системы ЮMoney, QIWI, СБП, а также наличными при получении (для некоторых способов доставки).",
|
||||
answer: "Мы принимаем различные способы оплаты: банковские карты (Visa, MasterCard, МИР), онлайн-платежи через системы ЮMoney, QIWI, СБП, а также наличными при получении (для некоторых способов доставки)."
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
question: "Могу ли я изменить или отменить заказ после его оформления?",
|
||||
answer:
|
||||
"Да, вы можете изменить или отменить заказ, если он еще не был отправлен. Для этого свяжитесь с нашей службой поддержки клиентов по телефону или электронной почте как можно скорее.",
|
||||
},
|
||||
],
|
||||
question: "Могу ли я изменить или отменить заказ?",
|
||||
answer: "Да, вы можете изменить или отменить заказ, если он еще не был отправлен. Для этого свяжитесь с нашей службой поддержки клиентов как можно скорее."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "delivery",
|
||||
title: "Доставка",
|
||||
icon: "🚚",
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
questions: [
|
||||
{
|
||||
id: "q4",
|
||||
question: "Какие способы доставки вы предлагаете?",
|
||||
answer:
|
||||
"Мы предлагаем несколько способов доставки: курьерская доставка, доставка в пункты выдачи заказов и доставка Почтой России. Доступные способы доставки могут различаться в зависимости от вашего региона.",
|
||||
answer: "Мы предлагаем курьерскую доставку, доставку в пункты выдачи заказов и доставку Почтой России. Доступные способы доставки зависят от вашего региона."
|
||||
},
|
||||
{
|
||||
id: "q5",
|
||||
question: "Сколько стоит доставка?",
|
||||
answer:
|
||||
"Стоимость доставки зависит от выбранного способа доставки и региона. Курьерская доставка по Москве стоит 500 рублей, доставка в пункты выдачи — 300 рублей, доставка Почтой России — 400 рублей. При заказе на сумму от 5000 рублей доставка бесплатная.",
|
||||
answer: "Стоимость доставки: курьерская по Москве - 500₽, пункты выдачи - 300₽, Почта России - 400₽. При заказе от 5000₽ доставка бесплатная."
|
||||
},
|
||||
{
|
||||
id: "q6",
|
||||
question: "Как долго осуществляется доставка?",
|
||||
answer:
|
||||
"Сроки доставки зависят от выбранного способа доставки и вашего региона. Курьерская доставка по Москве осуществляется в течение 1-2 дней, доставка в пункты выдачи — 2-3 дня, доставка Почтой России — 5-14 дней в зависимости от региона.",
|
||||
},
|
||||
],
|
||||
question: "Сроки доставки?",
|
||||
answer: "Курьерская доставка по Москве: 1-2 дня, пункты выдачи: 2-3 дня, Почта России: 5-14 дней в зависимости от региона."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "returns",
|
||||
title: "Возврат и обмен",
|
||||
icon: "↩️",
|
||||
icon: <RotateCcw className="h-5 w-5" />,
|
||||
questions: [
|
||||
{
|
||||
id: "q7",
|
||||
question: "Какие условия возврата товара?",
|
||||
answer:
|
||||
"Вы можете вернуть товар надлежащего качества в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки. Для возврата товара ненадлежащего качества срок составляет 30 дней.",
|
||||
question: "Условия возврата товара",
|
||||
answer: "Возврат товара надлежащего качества возможен в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки."
|
||||
},
|
||||
{
|
||||
id: "q8",
|
||||
question: "Как оформить возврат?",
|
||||
answer:
|
||||
"Для оформления возврата свяжитесь с нашей службой поддержки клиентов, заполните заявление на возврат и отправьте товар обратно. После получения и проверки товара мы вернем вам деньги тем же способом, которым была произведена оплата.",
|
||||
},
|
||||
answer: "Свяжитесь со службой поддержки, заполните заявление на возврат и отправьте товар. После проверки товара мы вернем деньги тем же способом оплаты."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
title: "Оплата",
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
questions: [
|
||||
{
|
||||
id: "q9",
|
||||
question: "Могу ли я обменять товар на другой размер или модель?",
|
||||
answer:
|
||||
"Да, вы можете обменять товар на другой размер или модель в течение 14 дней с момента получения. Для этого свяжитесь с нашей службой поддержки клиентов и следуйте инструкциям по обмену.",
|
||||
question: "Способы оплаты",
|
||||
answer: "Принимаем банковские карты (Visa, MasterCard, МИР), СБП и наличные при получении. Все онлайн-платежи защищены."
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
title: "Товары и размеры",
|
||||
icon: "👕",
|
||||
questions: [
|
||||
{
|
||||
id: "q10",
|
||||
question: "Как подобрать правильный размер?",
|
||||
answer:
|
||||
"Для подбора правильного размера воспользуйтесь нашей таблицей размеров, которая доступна на странице каждого товара и в разделе 'Таблица размеров'. Вы также можете воспользоваться нашим калькулятором размеров, который поможет подобрать идеальный размер на основе ваших измерений.",
|
||||
},
|
||||
{
|
||||
id: "q11",
|
||||
question: "Из каких материалов изготовлена ваша одежда?",
|
||||
answer:
|
||||
"Мы используем только высококачественные материалы для нашей одежды, включая натуральные ткани (хлопок, лен, шелк, шерсть) и современные синтетические материалы. Подробная информация о составе каждого изделия указана на странице товара.",
|
||||
},
|
||||
{
|
||||
id: "q12",
|
||||
question: "Как ухаживать за приобретенной одеждой?",
|
||||
answer:
|
||||
"Рекомендации по уходу зависят от типа изделия и используемых материалов. Общие рекомендации: стирать при температуре, указанной на этикетке, использовать мягкие моющие средства, не отбеливать, сушить в расправленном виде, гладить при температуре, соответствующей типу ткани.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
title: "Личный кабинет",
|
||||
icon: "👤",
|
||||
questions: [
|
||||
{
|
||||
id: "q13",
|
||||
question: "Как зарегистрироваться на сайте?",
|
||||
answer:
|
||||
"Для регистрации на сайте нажмите на иконку профиля в верхнем правом углу, выберите 'Регистрация', заполните необходимые поля (имя, email, пароль) и нажмите кнопку 'Зарегистрироваться'.",
|
||||
},
|
||||
{
|
||||
id: "q14",
|
||||
question: "Что делать, если я забыл пароль?",
|
||||
answer:
|
||||
"Если вы забыли пароль, нажмите на ссылку 'Забыли пароль?' на странице входа, введите email, указанный при регистрации, и следуйте инструкциям, которые будут отправлены на вашу почту.",
|
||||
},
|
||||
{
|
||||
id: "q15",
|
||||
question: "Как отслеживать статус заказа в личном кабинете?",
|
||||
answer:
|
||||
"Для отслеживания статуса заказа войдите в личный кабинет, перейдите в раздел 'Мои заказы', выберите интересующий вас заказ и просмотрите информацию о его текущем статусе.",
|
||||
},
|
||||
],
|
||||
},
|
||||
question: "Безопасность платежей",
|
||||
answer: "Все платежи проходят через защищенное соединение. Мы не храним данные карт. Платежи обрабатываются надежными системами по международным стандартам."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setOpenCategories((prev) =>
|
||||
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
|
||||
)
|
||||
// Функция для плавной прокрутки к секции
|
||||
const scrollToCategory = (categoryId: string) => {
|
||||
setActiveCategory(categoryId)
|
||||
const element = document.getElementById(categoryId)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleQuestion = (questionId: string) => {
|
||||
setOpenQuestions((prev) =>
|
||||
prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId],
|
||||
// Фильтрация вопросов по поисковому запросу
|
||||
const filteredCategories = faqCategories.map(category => ({
|
||||
...category,
|
||||
questions: category.questions.filter(q =>
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// Filter FAQ based on search query
|
||||
const filteredFAQ = faqCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
questions: category.questions.filter(
|
||||
(q) =>
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((category) => category.questions.length > 0)
|
||||
})).filter(category => category.questions.length > 0)
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] md:min-h-[400px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Боковая навигация */}
|
||||
<aside className="md:w-64 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по вопросам..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{faqCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => scrollToCategory(category.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors",
|
||||
activeCategory === category.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{category.icon}
|
||||
{category.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
{/* Основной контент */}
|
||||
<main className="flex-1 max-w-3xl">
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
animate={inView1 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-light mb-4 tracking-tight">Часто задаваемые вопросы</h1>
|
||||
<p className="text-lg md:text-xl opacity-90 font-light">
|
||||
Найдите ответы на самые распространенные вопросы о нашем магазине, товарах и услугах
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Search */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView1 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-10 md:mb-12"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по вопросам..."
|
||||
className="pl-10 py-3 md:py-6 text-base md:text-lg border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-12 bg-muted rounded-lg">
|
||||
<p className="text-muted-foreground mb-4">По вашему запросу ничего не найдено</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
Сбросить поиск
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* FAQ Categories */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView2 ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-6 md:space-y-8"
|
||||
>
|
||||
{filteredFAQ.length > 0 ? (
|
||||
filteredFAQ.map((category, categoryIndex) => (
|
||||
<motion.div
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{filteredCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: categoryIndex * 0.1 }}
|
||||
className="border border-gray-100 overflow-hidden"
|
||||
id={category.id}
|
||||
className="space-y-6 scroll-mt-24"
|
||||
>
|
||||
<button
|
||||
className={`w-full flex items-center justify-between p-4 md:p-6 text-left ${
|
||||
openCategories.includes(category.id) ? "bg-primary/5" : "bg-white"
|
||||
}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-2xl mr-3">{category.icon}</span>
|
||||
<h2 className="text-lg md:text-xl font-medium text-primary">{category.title}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
{category.icon}
|
||||
</div>
|
||||
{openCategories.includes(category.id) ? (
|
||||
<Minus className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
<h2 className="text-3xl font-semibold">{category.title}</h2>
|
||||
</div>
|
||||
|
||||
{openCategories.includes(category.id) && (
|
||||
<div className="border-t border-gray-100">
|
||||
{category.questions.map((question, questionIndex) => (
|
||||
<div key={question.id} className="border-b border-gray-100 last:border-b-0">
|
||||
<button
|
||||
className={`w-full flex items-center justify-between p-4 md:p-6 text-left ${
|
||||
openQuestions.includes(question.id) ? "bg-tertiary/5" : "bg-white"
|
||||
}`}
|
||||
onClick={() => toggleQuestion(question.id)}
|
||||
>
|
||||
<h3 className="text-base md:text-lg font-medium text-gray-800 pr-8">{question.question}</h3>
|
||||
{openQuestions.includes(question.id) ? (
|
||||
<Minus className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
{openQuestions.includes(question.id) && (
|
||||
<div className="p-4 md:p-6 pt-0 md:pt-0 text-gray-600 text-sm md:text-base">
|
||||
<p>{question.answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-gray-50 border border-gray-100">
|
||||
<p className="text-gray-500 mb-4">По вашему запросу ничего не найдено</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
Сбросить поиск
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{category.questions.map((q) => (
|
||||
<AccordionItem key={q.id} value={q.id}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{q.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Секция поддержки */}
|
||||
<div className="mt-16 p-6 bg-muted rounded-lg">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold mb-2">Не нашли ответ?</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Свяжитесь с нашей службой поддержки, и мы поможем решить ваш вопрос
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/contacts">
|
||||
Написать в поддержку
|
||||
<Mail className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/catalog">
|
||||
В каталог
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-12 md:py-20 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-light text-primary mb-4 tracking-tight">Не нашли ответ на свой вопрос?</h2>
|
||||
<p className="text-gray-600 mb-8 text-base md:text-lg">
|
||||
Свяжитесь с нашей службой поддержки, и мы с радостью поможем вам решить любой вопрос
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">
|
||||
Связаться с нами
|
||||
<Mail className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Перейти в каталог
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import { SiteFooter } from "@/components/layout/site-footer"
|
||||
import { CartProvider } from "@/hooks/use-cart-provider"
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode
|
||||
@ -8,9 +11,11 @@ interface MainLayoutProps {
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
<CartProvider>
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</CartProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,13 +4,9 @@ import { useState, useEffect, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { motion, useScroll, useTransform } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { motion, useScroll } from "framer-motion"
|
||||
|
||||
import { ArrowRight, ShoppingBag, Sparkles, Leaf, Hourglass, Heart } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
import { Product as ApiProduct, ProductDetails } from '@/lib/catalog'
|
||||
import catalogService from '@/lib/catalog'
|
||||
|
||||
// Типы данных
|
||||
interface Collection {
|
||||
@ -22,86 +18,38 @@ interface Collection {
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
// Интерфейс Продукта для использования на стороне клиента
|
||||
interface Product {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
price: number;
|
||||
image: string;
|
||||
isNew: boolean;
|
||||
sizes: string[];
|
||||
}
|
||||
|
||||
// Временные данные для отображения в случае ошибки API
|
||||
const tempFeaturedProducts: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
slug: "linen-dress-with-accent-belt",
|
||||
name: "Льняное платье с акцентным поясом",
|
||||
price: 12800,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
sizes: ["XS", "S", "M", "L", "XL"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
slug: "organic-cotton-blouse",
|
||||
name: "Блуза из органического хлопка",
|
||||
price: 7600,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
sizes: ["XS", "S", "M", "L"],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
slug: "high-waisted-pants",
|
||||
name: "Брюки с высокой посадкой",
|
||||
price: 8900,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
sizes: ["S", "M", "L", "XL"],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
slug: "loose-fit-jacket",
|
||||
name: "Жакет свободного кроя",
|
||||
price: 14500,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
sizes: ["S", "M", "L"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [heroRef, heroInView] = useInView({ triggerOnce: false, threshold: 0.1 })
|
||||
// Используем useRef для параллакс-эффекта
|
||||
const heroRef = useRef(null)
|
||||
const parallaxRef = useRef(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
// Настройка параллакс-эффекта при прокрутке
|
||||
useScroll({
|
||||
target: parallaxRef,
|
||||
offset: ["start start", "end start"]
|
||||
})
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, 200])
|
||||
// Используем параллакс-эффект при прокрутке
|
||||
|
||||
// Состояние для хранения загруженных товаров
|
||||
const [featuredProducts, setFeaturedProducts] = useState<ApiProduct[]>([])
|
||||
// Состояние для хранения статуса загрузки
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Коллекции (drops)
|
||||
const collections: Collection[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Dressed for Success",
|
||||
name: "Dressed for Success clothes",
|
||||
description: "Для вдохновения и продуктивности. Идеальные наряды для деловых встреч и творческих будней.",
|
||||
image: "/placeholder.svg?height=800&width=600",
|
||||
image: "/images/home/IMG_8382.jpeg",
|
||||
status: "Доступно сейчас",
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Chill & Active",
|
||||
name: "Chik & Active",
|
||||
description: "Комфортные комплекты для активного отдыха, спорта и расслабленных выходных.",
|
||||
image: "/placeholder.svg?height=800&width=600",
|
||||
image: "/images/home/drops/DFS_chik&active.svg",
|
||||
status: "Скоро",
|
||||
isAvailable: false,
|
||||
},
|
||||
@ -109,51 +57,27 @@ export default function HomePage() {
|
||||
id: 3,
|
||||
name: "Home & Sleep",
|
||||
description: "Уютные вещи для дома, отдыха и приятных сновидений.",
|
||||
image: "/placeholder.svg?height=800&width=600",
|
||||
image: "/images/home/drops/DFS_home&sleep.svg",
|
||||
status: "Скоро",
|
||||
isAvailable: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Секция избранных товаров
|
||||
const featuredProductIds = [18, 19, 2, 7]; // IDs товаров, которые нужно отобразить
|
||||
|
||||
// Загрузка избранных товаров при монтировании компонента
|
||||
// Имитация загрузки данных
|
||||
useEffect(() => {
|
||||
const fetchFeaturedProducts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Получаем все товары из API
|
||||
const response = await catalogService.getProducts({
|
||||
include_variants: true,
|
||||
is_active: true
|
||||
});
|
||||
|
||||
if (response && response.products) {
|
||||
// Фильтруем только те товары, которые указаны в featuredProductIds
|
||||
const filtered = response.products
|
||||
.filter((product) => featuredProductIds.includes(product.id));
|
||||
|
||||
setFeaturedProducts(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке избранных товаров:', error);
|
||||
// В случае ошибки загружаем временные данные
|
||||
setFeaturedProducts([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
// Имитируем загрузку данных
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
|
||||
fetchFeaturedProducts();
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="overflow-hidden">
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
ref={heroRef}
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative min-h-screen flex items-center overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 z-0">
|
||||
@ -161,31 +85,33 @@ export default function HomePage() {
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
initial={{ scale: 1.1, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: { duration: 1.5 }
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/images/hero/image-3.png"
|
||||
src="/images/home/image-7.jpg"
|
||||
alt="Dressed for Success Collection"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none z-[5] overflow-hidden">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute top-[10%] right-[5%] w-64 h-64 rounded-full border border-tertiary/20 opacity-60"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.6 }}
|
||||
transition={{ duration: 2, delay: 0.5 }}
|
||||
/>
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute bottom-[15%] left-[10%] w-40 h-40 rounded-full border border-tertiary/20 opacity-40"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.4 }}
|
||||
@ -202,45 +128,55 @@ export default function HomePage() {
|
||||
className="mb-4"
|
||||
>
|
||||
<span className="inline-block text-sm md:text-base tracking-widest text-tertiary font-light pb-1">
|
||||
Мы только открываемся
|
||||
Первая коллекция уже здесь ✨
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-serif text-white mb-6"
|
||||
>
|
||||
Мягкая сила. Новая женственность.
|
||||
Dressed for Success: Одетая в Успех
|
||||
</motion.h1>
|
||||
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="text-lg md:text-xl text-white/90 mb-8 font-light"
|
||||
>
|
||||
Первая коллекция уже доступна онлайн.
|
||||
Малые тиражи, максимум внимания к деталям.
|
||||
Создаем одежду из натуральных тканей, в которой вы чувствуете себя собой и готовы к победам.
|
||||
Женственная, но сильная эстетика, внимание к каждой детали и любовь к качеству ✨
|
||||
</motion.p>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
size="lg"
|
||||
className="bg-tertiary text-primary hover:bg-tertiary/90 group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ</span>
|
||||
<span className="relative z-10">ПОЗНАКОМИТЬСЯ С КОЛЛЕКЦИЕЙ</span>
|
||||
<span className="absolute inset-0 bg-white scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100 z-0"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white/10"
|
||||
>
|
||||
<Link href="#subscribe">
|
||||
ПОДПИШИСЬ
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
@ -250,7 +186,7 @@ export default function HomePage() {
|
||||
<section className="py-24 bg-tertiary/30 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@ -263,29 +199,31 @@ export default function HomePage() {
|
||||
alt="О бренде"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-40 h-40 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Наша философия</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Кто мы такие</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Dressed for Success: Больше, чем одежда</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Мы создаём одежду для женщины, которая выбирает быть собой. Комфорт. Легкость. Красота вне трендов.
|
||||
Dressed for Success – это философия, образ жизни, стремления и характер! Мы создаем одежду из натуральных тканей, которая подчеркивает женственность и силу одновременно. Каждая деталь продумана с любовью и вниманием. ✨
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Наши коллекции — это баланс между элегантностью и практичностью, между яркостью и утонченностью. Мы создаем одежду для тех, кто ценит качество, устойчивое развитие и уникальность.
|
||||
Мы верим, что правильный образ – первый шаг к успеху. Наша миссия – вдохновлять вас на победы, предлагая стильные, качественные и комфортные вещи из натуральных материалов, которые подчеркнут вашу индивидуальность и помогут выделиться.
|
||||
</p>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/about">
|
||||
@ -302,7 +240,7 @@ export default function HomePage() {
|
||||
{/* Декоративные элементы фона */}
|
||||
<div className="absolute top-20 left-0 w-40 h-40 bg-tertiary/10 rounded-full blur-3xl opacity-60"></div>
|
||||
<div className="absolute bottom-20 right-0 w-60 h-60 bg-primary/5 rounded-full blur-3xl opacity-70"></div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -312,70 +250,75 @@ export default function HomePage() {
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Коллекции</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Наши дропы</h2>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Коллекция Dressed for Success</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto text-lg">
|
||||
Ограниченные коллекции одежды, созданные с заботой о вас и планете
|
||||
Первая коллекция – это результат месяцев усердной работы, поиска идеальных натуральных материалов и бесконечного внимания к каждой детали. Женственная, но сильная эстетика в каждом изделии ✨
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{collections.map((collection, index) => (
|
||||
<motion.div
|
||||
key={collection.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.2 }}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-lg hover:shadow-xl transition-all duration-500"
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8}}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12"
|
||||
>
|
||||
{/* Левый столбец - Первая коллекция (большая) */}
|
||||
{collections.length > 0 && (
|
||||
<div
|
||||
key={collections[0].id}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-lg hover:shadow-xl transition-all duration-700 ease-in-out h-full"
|
||||
>
|
||||
{/* Статус коллекции */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<span className={`inline-block py-1 px-4 rounded-full text-xs uppercase tracking-wider font-medium ${
|
||||
collection.isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
collections[0].isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
: 'bg-primary/90 text-white'
|
||||
}`}>
|
||||
{collection.status}
|
||||
{collections[0].status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Изображение коллекции */}
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
src={collections[0].image}
|
||||
alt={collections[0].name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
|
||||
|
||||
|
||||
{/* Название коллекции на фоне */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 transform transition-transform duration-500 group-hover:translate-y-0">
|
||||
<h3 className="text-2xl font-serif text-white mb-2 drop-shadow-lg">{collection.name}</h3>
|
||||
<h3 className="text-3xl font-serif text-white mb-2 drop-shadow-lg">{collections[0].name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Информация и кнопка */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-700 mb-6 text-base">{collection.description}</p>
|
||||
|
||||
{collection.isAvailable ? (
|
||||
<Button
|
||||
<div className="p-8">
|
||||
<p className="text-gray-700 mb-6 text-lg">{collections[0].description}</p>
|
||||
|
||||
{collections[0].isAvailable ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
variant="outline"
|
||||
className="justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
>
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<Link href={`/collections/${collections[0].id}`}>
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ</span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
<Button
|
||||
disabled
|
||||
variant="outline"
|
||||
className="w-full justify-center border-secondary/30 text-secondary/50"
|
||||
variant="outline"
|
||||
className="justify-center border-secondary/30 text-secondary/50"
|
||||
>
|
||||
СКОРО В ПРОДАЖЕ
|
||||
</Button>
|
||||
@ -383,12 +326,81 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Декоративный элемент */}
|
||||
<div className="absolute -bottom-2 -right-2 w-16 h-16 rounded-full bg-tertiary/10 -z-10 transition-all duration-500 group-hover:scale-150 opacity-0 group-hover:opacity-100"></div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Правый столбец - Остальные коллекции (маленькие с фото) */}
|
||||
{collections.length > 1 && (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
{collections.slice(1).map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
className="group relative overflow-hidden rounded-xl bg-white shadow-md hover:shadow-lg transition-all duration-500 ease-in-out border border-gray-100 hover:border-tertiary/30 flex-1"
|
||||
>
|
||||
{/* Статус коллекции */}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<span className={`inline-block py-1 px-3 rounded-full text-xs uppercase tracking-wider font-medium ${
|
||||
collection.isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
: 'bg-primary/90 text-white'
|
||||
}`}>
|
||||
{collection.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
{/* Изображение коллекции */}
|
||||
<div className="relative md:w-1/3 aspect-[4/3] md:aspect-auto overflow-hidden bg-white flex items-center justify-center">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-contain transition-transform duration-700 group-hover:scale-105"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Информация и кнопка */}
|
||||
<div className="p-5 md:w-2/3 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-serif text-primary mb-3">{collection.name}</h3>
|
||||
<p className="text-gray-700 mb-5 text-sm">{collection.description}</p>
|
||||
</div>
|
||||
|
||||
{collection.isAvailable ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
>
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<span className="relative z-10">СМОТРЕТЬ</span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-center border-secondary/30 text-secondary/50"
|
||||
>
|
||||
СКОРО В ПРОДАЖЕ
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -396,15 +408,15 @@ export default function HomePage() {
|
||||
<section className="py-20 bg-primary text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-5">
|
||||
<Image
|
||||
src="/pattern.svg"
|
||||
alt=""
|
||||
fill
|
||||
<Image
|
||||
src="/pattern.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="container max-w-4xl mx-auto px-4 text-center relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -412,13 +424,16 @@ export default function HomePage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif mb-8">Мы за силу мягкости. За наряды, в которых удобно и красиво жить свою жизнь.</h2>
|
||||
<div className="flex justify-center mb-6">
|
||||
<Hourglass className="h-12 w-12 text-tertiary/80" />
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif mb-8">«Одежда — это язык без слов. Я создаю коллекции, которые подчеркивают уникальность каждой женщины и вдохновляют на новые свершения.»</h2>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-[1px] bg-tertiary/60"></div>
|
||||
</div>
|
||||
<p className="mt-6 text-white/80 text-sm">
|
||||
SMALL BATCH PRODUCTION
|
||||
</p>
|
||||
{/* <p className="mt-6 text-white/80 text-lg italic">
|
||||
Кристина, создательница бренда
|
||||
</p> */}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
@ -428,7 +443,7 @@ export default function HomePage() {
|
||||
{/* Декоративные элементы фона */}
|
||||
<div className="absolute top-40 right-0 w-80 h-80 bg-tertiary/10 rounded-full blur-3xl opacity-60"></div>
|
||||
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-primary/5 rounded-full blur-3xl opacity-70"></div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -437,51 +452,182 @@ export default function HomePage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Новинки</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Избранные товары</h2>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Избранное</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Наши избранные коллекции</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto text-lg">
|
||||
Самые популярные модели этого сезона
|
||||
Откройте для себя наши невесомые платья AIR, сияющие блузы SHIK и игривые шорты IBIZA — созданные с любовью к деталям и натуральным тканям ✨
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{isLoading ? (
|
||||
// Отображаем скелетон загрузки
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
{[1, 2, 3, 4].map((_, index) => (
|
||||
<div className="grid grid-cols-1 gap-16">
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gray-200 h-80"></div>
|
||||
<div className="p-4">
|
||||
<div className="h-4 bg-gray-200 rounded-full mb-4 w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded-full w-1/2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded-full mb-6 w-1/3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded-full mb-8 w-2/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2].map((_, idx) => (
|
||||
<div key={idx} className="relative overflow-hidden rounded-3xl bg-gray-200 h-80"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Отображаем загруженные товары, используя компонент ProductCard
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
{featuredProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.1 }}
|
||||
>
|
||||
<ProductCard product={product} />
|
||||
</motion.div>
|
||||
))}
|
||||
// Отображаем товары по категориям
|
||||
<div className="space-y-20">
|
||||
{/* Категория Платья AIR */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section mb-20 border-b border-gray-100 pb-16"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Фото */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[2/3] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/IMG_9028.jpeg"
|
||||
alt="Платья AIR"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -right-4 w-24 h-24 bg-tertiary/10 rounded-full -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Описание */}
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Leaf className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Платья AIR: Воплощение Легкости и Женственности 🕊️
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Откройте для себя наши невесомые платья из линейки AIR. Созданные из тончайшего натурального тенсела (на каждое уходит до 5 метров ткани!), они ощущаются как вторая кожа. Идеальны для любого случая – носите с кедами в городе или с босоножками на отдыхе. Женственный силуэт подчеркнет вашу индивидуальность и придаст уверенности.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=dresses">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Категория Блузы SHIK */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section mb-20 border-b border-gray-100 pb-16"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Описание */}
|
||||
<div className="order-2 md:order-1">
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Sparkles className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Блузы SHIK: Созданы для Вашего Сияния ✨
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Коллекция блуз SHIK – это гимн успеху и личностному росту. Невесомые, сделанные из тончайшего натурального тенсела, они абсолютно легкие и невероятно приятные к телу. Каждую блузу украшают 17 роскошных пуговиц – число, символизирующее процветание. Внимание к деталям и качество исполнения делают эти блузы особенными.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=blouses">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Фото */}
|
||||
<div className="relative order-1 md:order-2">
|
||||
<div className="relative aspect-[2/3] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/IMG_9135.jpeg"
|
||||
alt="Блузы SHIK"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -top-4 -left-4 w-24 h-24 bg-primary/5 rounded-full -z-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Категория Шорты IBIZA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Фото */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[2/3] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/IMG_9138.jpeg"
|
||||
alt="Шорты IBIZA"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -left-4 w-24 h-24 bg-tertiary/10 rounded-full -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Описание */}
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Heart className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Шорты IBIZA: Дух Авантюризма и Комфорт 🌪️
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Для легких, смелых и игривых! Шорты IBIZA созданы для тех, кто ценит свободу и комфорт. Натуральные ткани (лён + вискоза) и универсальная длина делают их идеальными как для динамичной городской жизни, так и для расслабленного отдыха. Сильная и женственная эстетика в каждой детали. Добавьте куража в свой образ!
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=shorts">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Кнопка для перехода в каталог */}
|
||||
<div className="text-center mt-16">
|
||||
<Button
|
||||
<div className="text-center mt-20">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden"
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden px-8 py-6 text-lg"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10 flex items-center">СМОТРЕТЬ БОЛЬШЕ ТОВАРОВ <ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="relative z-10 flex items-center">ПОЛНЫЙ КАТАЛОГ КОЛЛЕКЦИИ <ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" /></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@ -492,35 +638,32 @@ export default function HomePage() {
|
||||
<section className="py-20 bg-tertiary/30 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Как носить</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Вдохновение</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed italic">
|
||||
"Каждая деталь имеет значение — ткань, форма, цвет. Я создаю вещи, которые помогают женщинам чувствовать себя уверенно и комфортно, независимо от ситуации."
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4 font-medium">
|
||||
Алина, основательница бренда
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Ваш Стиль</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Вдохновение от Dressed for Success</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Мы верим, что стиль – это способ рассказать о себе миру. Не бойтесь проявлять себя и отражать свой характер через повседневные образы! Натуральные ткани и внимание к деталям делают каждую вещь особенной. 🔥
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8">
|
||||
Сочетайте базовые модели с акцентными вещами для создания универсальных образов на каждый день.
|
||||
Сочетайте наши невесомые платья AIR с грубыми ботинками или элегантными лодочками. Носите сияющие блузы SHIK с классическими брюками для деловых встреч или с шортами IBIZA для создания яркого и дерзкого образа. Женственная, но сильная эстетика в каждом сочетании.
|
||||
</p>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/blog">
|
||||
<Link href="/catalog">
|
||||
СОВЕТЫ ПО СТИЛЮ
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@ -533,6 +676,8 @@ export default function HomePage() {
|
||||
alt="Lookbook"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
</div>
|
||||
@ -542,13 +687,13 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* Подписка на рассылку */}
|
||||
<section className="py-24 bg-primary relative overflow-hidden">
|
||||
<section id="subscribe" className="py-24 bg-primary relative overflow-hidden">
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -right-20 -top-20 w-80 h-80 rounded-full border border-white/10 opacity-30"></div>
|
||||
<div className="absolute -left-40 bottom-0 w-96 h-96 rounded-full border border-white/10 opacity-20"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<motion.div
|
||||
@ -557,32 +702,34 @@ export default function HomePage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-white mb-6">Будь первой</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-white mb-6">Будь первой ✨</h2>
|
||||
<p className="text-white/80 mb-8 leading-relaxed">
|
||||
Новые дропы, эксклюзивные предложения и полезные статьи о стиле
|
||||
Новые коллекции, эксклюзивные предложения и полезные статьи о стиле. Подпишитесь на наши социальные сети, чтобы быть в курсе всех новостей и получать вдохновение каждый день!
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 md:p-12">
|
||||
|
||||
{/* <div className="bg-white rounded-2xl shadow-lg p-8 md:p-12">
|
||||
<form className="flex flex-col md:flex-row gap-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Ваш email"
|
||||
className="flex-1 border-gray-200 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary text-white hover:bg-primary/90"
|
||||
>
|
||||
Подписаться
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-6 mt-12">
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
</div> */}
|
||||
|
||||
<div className="flex justify-center gap-10 mt-12">
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold flex items-center gap-2">
|
||||
<ShoppingBag className="w-6 h-6" />
|
||||
Instagram
|
||||
</Link>
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
Telegram
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
322
frontend/app/(main)/privacy/page.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="overflow-hidden bg-white min-h-screen relative">
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-24 bg-gradient-to-b from-primary/10 to-white overflow-hidden">
|
||||
{/* Декоративные круги */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-5xl lg:text-6xl font-serif text-primary mb-6 text-center"
|
||||
>
|
||||
Политика конфиденциальности
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-gray-700 max-w-3xl mx-auto text-center mb-12"
|
||||
>
|
||||
Политика в отношении обработки персональных данных
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-4xl mx-auto space-y-10"
|
||||
>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">1. Общие положения</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. № 152-ФЗ «О персональных данных» (далее — Закон о персональных данных) и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые ИП Плотниковым Михаилом Владимировичем (далее — Оператор).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее — Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">2. Основные понятия, используемые в Политике</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
2.1. Автоматизированная обработка персональных данных — обработка персональных данных с помощью средств вычислительной техники.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
2.2. Блокирование персональных данных — временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
2.3. Веб-сайт — совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных и обеспечивающих их обработку информационных технологий и технических средств.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.6. Обработка персональных данных — любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.7. Оператор — государственный орган, муниципальный орган, юридическое или физическое лицо, самостоятельно или совместно с другими лицами организующие и/или осуществляющие обработку персональных данных, а также определяющие цели обработки персональных данных, состав персональных данных, подлежащих обработке, действия (операции), совершаемые с персональными данными.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.8. Персональные данные — любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.9. Персональные данные, разрешенные субъектом персональных данных для распространения, — персональные данные, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных путем дачи согласия на обработку персональных данных, разрешенных субъектом персональных данных для распространения в порядке, предусмотренном Законом о персональных данных (далее — персональные данные, разрешенные для распространения).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.10. Пользователь — любой посетитель веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.11. Предоставление персональных данных — действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.12. Распространение персональных данных — любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц (передача персональных данных) или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.13. Трансграничная передача персональных данных — передача персональных данных на территорию иностранного государства органу власти иностранного государства, иностранному физическому или иностранному юридическому лицу.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.14. Уничтожение персональных данных — любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и/или уничтожаются материальные носители персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">3. Основные права и обязанности Оператора</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
3.1. Оператор имеет право:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>получать от субъекта персональных данных достоверные информацию и/или документы, содержащие персональные данные;</li>
|
||||
<li>в случае отзыва субъектом персональных данных согласия на обработку персональных данных, а также, направления обращения с требованием о прекращении обработки персональных данных, Оператор вправе продолжить обработку персональных данных без согласия субъекта персональных данных при наличии оснований, указанных в Законе о персональных данных;</li>
|
||||
<li>самостоятельно определять состав и перечень мер, необходимых и достаточных для обеспечения выполнения обязанностей, предусмотренных Законом о персональных данных и принятыми в соответствии с ним нормативными правовыми актами, если иное не предусмотрено Законом о персональных данных или другими федеральными законами.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
3.2. Оператор обязан:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>предоставлять субъекту персональных данных по его просьбе информацию, касающуюся обработки его персональных данных;</li>
|
||||
<li>организовывать обработку персональных данных в порядке, установленном действующим законодательством РФ;</li>
|
||||
<li>отвечать на обращения и запросы субъектов персональных данных и их законных представителей в соответствии с требованиями Закона о персональных данных;</li>
|
||||
<li>сообщать в уполномоченный орган по защите прав субъектов персональных данных по запросу этого органа необходимую информацию в течение 10 дней с даты получения такого запроса;</li>
|
||||
<li>публиковать или иным образом обеспечивать неограниченный доступ к настоящей Политике в отношении обработки персональных данных;</li>
|
||||
<li>принимать правовые, организационные и технические меры для защиты персональных данных от неправомерного или случайного доступа к ним, уничтожения, изменения, блокирования, копирования, предоставления, распространения персональных данных, а также от иных неправомерных действий в отношении персональных данных;</li>
|
||||
<li>прекратить передачу (распространение, предоставление, доступ) персональных данных, прекратить обработку и уничтожить персональные данные в порядке и случаях, предусмотренных Законом о персональных данных;</li>
|
||||
<li>исполнять иные обязанности, предусмотренные Законом о персональных данных.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">4. Основные права и обязанности субъектов персональных данных</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
4.1. Субъекты персональных данных имеют право:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>получать информацию, касающуюся обработки его персональных данных, за исключением случаев, предусмотренных федеральными законами;</li>
|
||||
<li>требовать от оператора уточнения его персональных данных, их блокирования или уничтожения в случае, если персональные данные являются неполными, устаревшими, неточными, незаконно полученными или не являются необходимыми для заявленной цели обработки;</li>
|
||||
<li>выдвигать условие предварительного согласия при обработке персональных данных в целях продвижения на рынке товаров, работ и услуг;</li>
|
||||
<li>на отзыв согласия на обработку персональных данных;</li>
|
||||
<li>обжаловать в уполномоченный орган по защите прав субъектов персональных данных или в судебном порядке неправомерные действия или бездействие Оператора при обработке его персональных данных;</li>
|
||||
<li>на осуществление иных прав, предусмотренных законодательством РФ.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
4.2. Субъекты персональных данных обязаны:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>предоставлять Оператору достоверные данные о себе;</li>
|
||||
<li>сообщать Оператору об уточнении (обновлении, изменении) своих персональных данных.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
4.3. Лица, передавшие Оператору недостоверные сведения о себе, либо сведения о другом субъекте персональных данных без согласия последнего, несут ответственность в соответствии с законодательством РФ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">5. Принципы обработки персональных данных</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
5.1. Обработка персональных данных осуществляется на законной и справедливой основе.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.2. Обработка персональных данных ограничивается достижением конкретных, заранее определенных и законных целей. Не допускается обработка персональных данных, несовместимая с целями сбора персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.3. Не допускается объединение баз данных, содержащих персональные данные, обработка которых осуществляется в целях, несовместимых между собой.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.4. Обработке подлежат только персональные данные, которые отвечают целям их обработки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.5. Содержание и объем обрабатываемых персональных данных соответствуют заявленным целям обработки. Не допускается избыточность обрабатываемых персональных данных по отношению к заявленным целям их обработки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.6. При обработке персональных данных обеспечивается точность персональных данных, их достаточность, а в необходимых случаях и актуальность по отношению к целям обработки персональных данных. Оператор принимает необходимые меры и/или обеспечивает их принятие по удалению или уточнению неполных или неточных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.7. Хранение персональных данных осуществляется в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных. Обрабатываемые персональные данные уничтожаются либо обезличиваются по достижении целей обработки или в случае утраты необходимости в достижении этих целей, если иное не предусмотрено федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">6. Цели обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Цель обработки — уточнение деталей заказа, обработка заказов, доставка товаров, информирование о статусе заказа, отправка информационных писем.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Персональные данные — фамилия, имя, отчество; электронный адрес; номера телефонов; адрес доставки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Правовые основания — Федеральный закон «Об информации, информационных технологиях и о защите информации» от 27.07.2006 N 149-ФЗ
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Виды обработки персональных данных — Сбор, запись, систематизация, накопление, хранение, уничтожение и обезличивание персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">7. Условия обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
7.1. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.2. Обработка персональных данных необходима для достижения целей, предусмотренных международным договором Российской Федерации или законом, для осуществления возложенных законодательством Российской Федерации на оператора функций, полномочий и обязанностей.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.3. Обработка персональных данных необходима для осуществления правосудия, исполнения судебного акта, акта другого органа или должностного лица, подлежащих исполнению в соответствии с законодательством Российской Федерации об исполнительном производстве.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.4. Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем или поручителем.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.5. Обработка персональных данных необходима для осуществления прав и законных интересов оператора или третьих лиц либо для достижения общественно значимых целей при условии, что при этом не нарушаются права и свободы субъекта персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.6. Осуществляется обработка персональных данных, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных либо по его просьбе (далее — общедоступные персональные данные).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.7. Осуществляется обработка персональных данных, подлежащих опубликованию или обязательному раскрытию в соответствии с федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">8. Порядок сбора, хранения, передачи и других видов обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Безопасность персональных данных, которые обрабатываются Оператором, обеспечивается путем реализации правовых, организационных и технических мер, необходимых для выполнения в полном объеме требований действующего законодательства в области защиты персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.1. Оператор обеспечивает сохранность персональных данных и принимает все возможные меры, исключающие доступ к персональным данным неуполномоченных лиц.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.2. Персональные данные Пользователя никогда, ни при каких условиях не будут переданы третьим лицам, за исключением случаев, связанных с исполнением действующего законодательства либо в случае, если субъектом персональных данных дано согласие Оператору на передачу данных третьему лицу для исполнения обязательств по гражданско-правовому договору.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.3. В случае выявления неточностей в персональных данных, Пользователь может актуализировать их самостоятельно, путем направления Оператору уведомление на адрес электронной почты Оператора Pmv-84@yandex.ru с пометкой «Актуализация персональных данных».
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.4. Срок обработки персональных данных определяется достижением целей, для которых были собраны персональные данные, если иной срок не предусмотрен договором или действующим законодательством.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Пользователь может в любой момент отозвать свое согласие на обработку персональных данных, направив Оператору уведомление посредством электронной почты на электронный адрес Оператора Pmv-84@yandex.ru с пометкой «Отзыв согласия на обработку персональных данных».
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.5. Вся информация, которая собирается сторонними сервисами, в том числе платежными системами, средствами связи и другими поставщиками услуг, хранится и обрабатывается указанными лицами (Операторами) в соответствии с их Пользовательским соглашением и Политикой конфиденциальности. Субъект персональных данных и/или с указанными документами. Оператор не несет ответственность за действия третьих лиц, в том числе указанных в настоящем пункте поставщиков услуг.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.6. Установленные субъектом персональных данных запреты на передачу (кроме предоставления доступа), а также на обработку или условия обработки (кроме получения доступа) персональных данных, разрешенных для распространения, не действуют в случаях обработки персональных данных в государственных, общественных и иных публичных интересах, определенных законодательством РФ.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.7. Оператор при обработке персональных данных обеспечивает конфиденциальность персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.8. Оператор осуществляет хранение персональных данных в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.9. Условием прекращения обработки персональных данных может являться достижение целей обработки персональных данных, истечение срока действия согласия субъекта персональных данных, отзыв согласия субъектом персональных данных или требование о прекращении обработки персональных данных, а также выявление неправомерной обработки персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">9. Перечень действий, производимых Оператором с полученными персональными данными</h2>
|
||||
<p className="text-gray-700">
|
||||
9.1. Оператор осуществляет сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление и уничтожение персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
9.2. Оператор осуществляет автоматизированную обработку персональных данных с получением и/или передачей полученной информации по информационно-телекоммуникационным сетям или без таковой.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">10. Трансграничная передача персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
10.1. Оператор до начала осуществления деятельности по трансграничной передаче персональных данных обязан уведомить уполномоченный орган по защите прав субъектов персональных данных о своем намерении осуществлять трансграничную передачу персональных данных (такое уведомление направляется отдельно от уведомления о намерении осуществлять обработку персональных данных).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
10.2. Оператор до подачи вышеуказанного уведомления, обязан получить от органов власти иностранного государства, иностранных физических лиц, иностранных юридических лиц, которым планируется трансграничная передача персональных данных, соответствующие сведения.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">11. Конфиденциальность персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Оператор и иные лица, получившие доступ к персональным данным, обязаны не раскрывать третьим лицам и не распространять персональные данные без согласия субъекта персональных данных, если иное не предусмотрено федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">12. Заключительные положения</h2>
|
||||
<p className="text-gray-700">
|
||||
12.1. Пользователь может получить любые разъяснения по интересующим вопросам, касающимся обработки его персональных данных, обратившись к Оператору с помощью электронной почты Pmv-84@yandex.ru.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
12.2. В данном документе будут отражены любые изменения политики обработки персональных данных Оператором. Политика действует бессрочно до замены ее новой версией.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
12.3. Актуальная версия Политики в свободном доступе расположена в сети Интернет по адресу https://dressedforsuccess.shop/privacy.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Кнопка возврата на главную */}
|
||||
<section className="py-12 bg-tertiary/5">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.6 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary text-white rounded-full hover:bg-primary/90 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { motion } from "framer-motion"
|
||||
import { Ruler, Info } from "lucide-react"
|
||||
import { SizeTable } from "@/components/size-guide/size-table"
|
||||
|
||||
export default function SizeGuidePage() {
|
||||
const [gender, setGender] = useState("women")
|
||||
@ -69,418 +70,11 @@ export default function SizeGuidePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] bg-gray-100">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">Таблица размеров</h1>
|
||||
<p className="text-xl opacity-90">Подберите идеальный размер для вашей фигуры</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Как правильно снять мерки</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto">
|
||||
Чтобы подобрать идеальный размер, важно правильно снять мерки. Следуйте нашим рекомендациям для
|
||||
получения точных измерений.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 mb-16">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary mb-4">Советы по измерению</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Используйте мягкую измерительную ленту</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Для точных измерений используйте мягкую портновскую ленту.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Измеряйте в нижнем белье</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Для получения точных измерений снимайте мерки в облегающем нижнем белье.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Держите ленту параллельно полу</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
При измерении обхватов следите, чтобы лента была параллельна полу.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Не затягивайте ленту слишком туго</p>
|
||||
<p className="text-gray-600 text-sm">Лента должна прилегать к телу, но не стягивать его.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary mb-4">Что измерять</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват груди</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте по наиболее выступающим точкам груди, лента должна проходить через лопатки.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват талии</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте по самой узкой части талии, обычно на 2-3 см выше пупка.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват бедер</p>
|
||||
<p className="text-gray-600 text-sm">Измерьте по самым выступающим точкам ягодиц.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Рост</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте расстояние от макушки до пола, стоя прямо без обуви.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100 mb-16">
|
||||
<h3 className="text-2xl font-bold text-primary mb-6">Подберите свой размер</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<Label className="mb-2 block">Выберите пол</Label>
|
||||
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="women" id="women" />
|
||||
<Label htmlFor="women">Женщины</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="men" id="men" />
|
||||
<Label htmlFor="men">Мужчины</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="mb-2 block">Единицы измерения</Label>
|
||||
<RadioGroup value={measurementSystem} onValueChange={setMeasurementSystem} className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="cm" id="cm" />
|
||||
<Label htmlFor="cm">Сантиметры</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inch" id="inch" />
|
||||
<Label htmlFor="inch">Дюймы</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="height">Рост</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "170" : "67"}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="weight">Вес</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "60" : "132"}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "кг" : "lb"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bust">Обхват груди</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="bust"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "90" : "35"}
|
||||
value={bust}
|
||||
onChange={(e) => setBust(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="waist">Обхват талии</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="waist"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "70" : "28"}
|
||||
value={waist}
|
||||
onChange={(e) => setWaist(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hips">Обхват бедер</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="hips"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "95" : "37"}
|
||||
value={hips}
|
||||
onChange={(e) => setHips(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button className="bg-primary hover:bg-secondary rounded-full" onClick={calculateSize}>
|
||||
Рассчитать мой размер
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary hover:text-white rounded-full"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recommendedSize && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-tertiary/50 p-6 rounded-xl flex items-start"
|
||||
>
|
||||
<Info className="h-6 w-6 mr-3 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-1">Ваш рекомендуемый размер: {recommendedSize}</h3>
|
||||
<p className="text-gray-700">
|
||||
Это приблизительный размер, основанный на ваших измерениях. Размеры могут отличаться в
|
||||
зависимости от модели и фасона.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-primary mb-6">Таблицы размеров</h3>
|
||||
|
||||
<Tabs defaultValue="women" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 mb-6">
|
||||
<TabsTrigger value="women">Женская одежда</TabsTrigger>
|
||||
<TabsTrigger value="men">Мужская одежда</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="women" className="pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-tertiary/50">
|
||||
<th className="border border-gray-200 p-3 text-left">Российский размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Международный размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват груди (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват талии (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват бедер (см)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">40-42</td>
|
||||
<td className="border border-gray-200 p-3">XS</td>
|
||||
<td className="border border-gray-200 p-3">80-84</td>
|
||||
<td className="border border-gray-200 p-3">62-66</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">42-44</td>
|
||||
<td className="border border-gray-200 p-3">S</td>
|
||||
<td className="border border-gray-200 p-3">84-88</td>
|
||||
<td className="border border-gray-200 p-3">66-70</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">44-46</td>
|
||||
<td className="border border-gray-200 p-3">M</td>
|
||||
<td className="border border-gray-200 p-3">88-92</td>
|
||||
<td className="border border-gray-200 p-3">70-74</td>
|
||||
<td className="border border-gray-200 p-3">94-98</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">46-48</td>
|
||||
<td className="border border-gray-200 p-3">L</td>
|
||||
<td className="border border-gray-200 p-3">92-96</td>
|
||||
<td className="border border-gray-200 p-3">74-78</td>
|
||||
<td className="border border-gray-200 p-3">98-102</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">48-50</td>
|
||||
<td className="border border-gray-200 p-3">XL</td>
|
||||
<td className="border border-gray-200 p-3">96-100</td>
|
||||
<td className="border border-gray-200 p-3">78-82</td>
|
||||
<td className="border border-gray-200 p-3">102-106</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="men" className="pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-tertiary/50">
|
||||
<th className="border border-gray-200 p-3 text-left">Российский размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Международный размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват груди (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват талии (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват шеи (см)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">44-46</td>
|
||||
<td className="border border-gray-200 p-3">XS</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
<td className="border border-gray-200 p-3">74-78</td>
|
||||
<td className="border border-gray-200 p-3">36-37</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">46-48</td>
|
||||
<td className="border border-gray-200 p-3">S</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
<td className="border border-gray-200 p-3">78-82</td>
|
||||
<td className="border border-gray-200 p-3">37-38</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">48-50</td>
|
||||
<td className="border border-gray-200 p-3">M</td>
|
||||
<td className="border border-gray-200 p-3">94-98</td>
|
||||
<td className="border border-gray-200 p-3">82-86</td>
|
||||
<td className="border border-gray-200 p-3">38-39</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">50-52</td>
|
||||
<td className="border border-gray-200 p-3">L</td>
|
||||
<td className="border border-gray-200 p-3">98-102</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
<td className="border border-gray-200 p-3">39-40</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">52-54</td>
|
||||
<td className="border border-gray-200 p-3">XL</td>
|
||||
<td className="border border-gray-200 p-3">102-106</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
<td className="border border-gray-200 p-3">40-41</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 bg-tertiary/30">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-bold text-primary mb-4">Нужна помощь с выбором размера?</h2>
|
||||
<p className="text-gray-700 mb-8 max-w-2xl mx-auto">
|
||||
Если у вас остались вопросы или вам нужна дополнительная помощь с выбором размера, наши консультанты всегда
|
||||
готовы помочь.
|
||||
</p>
|
||||
<Button asChild className="bg-primary hover:bg-secondary rounded-full px-8">
|
||||
<a href="/contact">Связаться с нами</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-semibold mb-8 text-center">Таблица размеров</h1>
|
||||
<SizeTable />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,258 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { motion } from "framer-motion"
|
||||
import { Heart, ShoppingBag, Trash2, AlertCircle } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Heart, Trash2 } from "lucide-react"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
import catalogService, { ProductDetails } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
const recommended = [
|
||||
{
|
||||
id: 5,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 3490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "skirt-midi",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 12990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "coat-wool",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
|
||||
price: 7990,
|
||||
oldPrice: 9990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "sweater-cashmere",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "РУБАШКА ОВЕРСАЙЗ",
|
||||
price: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "shirt-oversize",
|
||||
},
|
||||
]
|
||||
|
||||
export default function WishlistPage() {
|
||||
// Mock wishlist items
|
||||
const [wishlistItems, setWishlistItems] = useState([
|
||||
{
|
||||
id: 1,
|
||||
name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
|
||||
price: 5990,
|
||||
oldPrice: 7990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
|
||||
price: 4990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ",
|
||||
price: 3990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ЖАКЕТ В КЛЕТКУ",
|
||||
price: 6990,
|
||||
oldPrice: 8990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
])
|
||||
const { items, removeItem, clearWishlist } = useWishlist()
|
||||
const [products, setProducts] = useState<ProductDetails[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const removeFromWishlist = (id: number) => {
|
||||
setWishlistItems(wishlistItems.filter((item) => item.id !== id))
|
||||
}
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
async function fetchProducts() {
|
||||
setLoading(true)
|
||||
const results: ProductDetails[] = []
|
||||
for (const item of items) {
|
||||
try {
|
||||
const product = await catalogService.getProductById(item.product_id)
|
||||
if (isMounted && product) results.push(product)
|
||||
} catch (e) {
|
||||
// ignore not found
|
||||
}
|
||||
}
|
||||
if (isMounted) setProducts(results)
|
||||
setLoading(false)
|
||||
}
|
||||
if (items.length > 0) fetchProducts()
|
||||
else {
|
||||
setProducts([])
|
||||
setLoading(false)
|
||||
}
|
||||
return () => { isMounted = false }
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] bg-gray-900">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70"></div>
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="flex items-center justify-between mb-8 md:mb-12">
|
||||
<Link href="/catalog" className="text-primary hover:text-primary/80 flex items-center text-sm md:text-base transition-colors duration-200">
|
||||
<Heart className="h-5 w-5 mr-2" />
|
||||
Вернуться к покупкам
|
||||
</Link>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary tracking-tight">Избранное</h1>
|
||||
<div className="w-[120px] md:hidden"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-arimo font-bold mb-4 uppercase tracking-tight">ИЗБРАННОЕ</h1>
|
||||
<p className="text-xl opacity-90 font-arimo">Ваша персональная коллекция любимых товаров</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
{wishlistItems.length === 0 ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-100 mb-6">
|
||||
<Heart className="h-12 w-12 text-black" />
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.5 }} className="py-16 md:py-24 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center gap-4">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-72 w-56 rounded-3xl" />)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-arimo font-medium text-black mb-4 uppercase">Ваш список избранного пуст</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto font-arimo">
|
||||
</motion.div>
|
||||
) : products.length === 0 ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center py-16 md:py-24 max-w-2xl mx-auto"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 md:w-32 md:h-32 bg-tertiary/20 rounded-full mb-8">
|
||||
<Heart className="h-12 w-12 md:h-16 md:w-16 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-medium text-primary mb-4">Ваш список избранного пуст</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Добавляйте понравившиеся товары в избранное, чтобы не потерять их и быстро вернуться к ним позже.
|
||||
</p>
|
||||
<Button asChild className="bg-black hover:bg-gray-800 text-white px-8 font-arimo">
|
||||
<Link href="/catalog">ПЕРЕЙТИ В КАТАЛОГ</Link>
|
||||
<Button asChild className="bg-primary hover:bg-primary/90 text-white px-8">
|
||||
<Link href="/catalog">Перейти в каталог</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div>
|
||||
<motion.div
|
||||
key="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-arimo font-bold text-black uppercase tracking-tight">
|
||||
ТОВАРЫ В ИЗБРАННОМ ({wishlistItems.length})
|
||||
<h2 className="text-xl md:text-2xl font-semibold text-primary uppercase tracking-tight">
|
||||
Товары в избранном ({products.length})
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black text-black hover:bg-black hover:text-white font-arimo"
|
||||
onClick={() => setWishlistItems([])}
|
||||
className="border-primary text-primary hover:bg-primary hover:text-white"
|
||||
onClick={clearWishlist}
|
||||
>
|
||||
ОЧИСТИТЬ СПИСОК
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Очистить список
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{wishlistItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="bg-white border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<Image
|
||||
src={item.image || "/placeholder.svg"}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 bg-white p-2 shadow-md hover:bg-gray-100 transition-colors"
|
||||
onClick={() => removeFromWishlist(item.id)}
|
||||
>
|
||||
<Trash2 className="h-5 w-5 text-black" />
|
||||
<span className="sr-only">Удалить из избранного</span>
|
||||
</button>
|
||||
|
||||
{!item.inStock && (
|
||||
<div className="absolute inset-0 bg-white/80 flex flex-col items-center justify-center p-4">
|
||||
<AlertCircle className="h-8 w-8 text-gray-500 mb-2" />
|
||||
<p className="font-arimo font-medium text-black text-center">НЕТ В НАЛИЧИИ</p>
|
||||
<p className="text-sm text-gray-600 text-center mt-1 font-arimo">Сообщить, когда появится</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link href={`/product/${item.id}`} className="block">
|
||||
<h3 className="text-lg font-arimo font-medium text-black mb-2 hover:underline uppercase">
|
||||
{item.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="font-arimo font-bold text-black">{item.price.toLocaleString()} ₽</span>
|
||||
{item.oldPrice && (
|
||||
<span className="text-gray-500 line-through text-sm font-arimo">
|
||||
{item.oldPrice.toLocaleString()} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{item.inStock ? (
|
||||
<>
|
||||
<Button asChild className="flex-1 bg-black hover:bg-gray-800 text-white font-arimo">
|
||||
<Link href={`/product/${item.id}`}>ПОДРОБНЕЕ</Link>
|
||||
</Button>
|
||||
<Button className="flex-1 bg-gray-100 text-black hover:bg-gray-200 font-arimo">
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />В КОРЗИНУ
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-default font-arimo"
|
||||
>
|
||||
<Link href={`/product/${item.id}`}>ПОДРОБНЕЕ</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="relative">
|
||||
<ProductCard
|
||||
product={product}
|
||||
showWishlistButton={false}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-20 opacity-100"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeItem(product.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-primary" />
|
||||
<span className="sr-only">Удалить из избранного</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recommended Products */}
|
||||
<section className="py-12 bg-gray-100">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-2xl font-arimo font-bold text-black mb-8 uppercase tracking-tight">РЕКОМЕНДУЕМ ВАМ</h2>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Рекомендации */}
|
||||
<section className="py-12 md:py-20">
|
||||
<h2 className="text-xl md:text-2xl font-semibold text-primary mb-8 uppercase tracking-tight">Рекомендуем вам</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{
|
||||
id: 5,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 3490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 12990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
|
||||
price: 7990,
|
||||
oldPrice: 9990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "РУБАШКА ОВЕРСАЙЗ",
|
||||
price: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.id} className="bg-white border border-gray-200 overflow-hidden group">
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<Image
|
||||
src={item.image || "/placeholder.svg"}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<button className="absolute top-4 right-4 bg-white p-2 shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Heart className="h-5 w-5 text-black" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link href={`/product/${item.id}`} className="block">
|
||||
<h3 className="text-lg font-arimo font-medium text-black mb-2 group-hover:underline uppercase">
|
||||
{item.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="font-arimo font-bold text-black">{item.price.toLocaleString()} ₽</span>
|
||||
{item.oldPrice && (
|
||||
<span className="text-gray-500 line-through text-sm font-arimo">
|
||||
{item.oldPrice.toLocaleString()} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full bg-gray-100 text-black hover:bg-gray-200 font-arimo">
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />В КОРЗИНУ
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{recommended.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
price={item.price}
|
||||
image={item.image}
|
||||
isOnSale={!!item.oldPrice}
|
||||
salePrice={item.oldPrice ? item.price : undefined}
|
||||
slug={item.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/app/.DS_Store
vendored
11
frontend/app/LazyBgLoader.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function LazyBgLoader() {
|
||||
useEffect(() => {
|
||||
import("./lazy-background");
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||