Compare commits

...

3 Commits

569 changed files with 15779 additions and 54233 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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
View File

@ -0,0 +1 @@
node_modules

View File

@ -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"]

View File

@ -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"]

View 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

View 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
backend/.DS_Store vendored

Binary file not shown.

13
backend/.env Normal file
View 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

View File

@ -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
View 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

View File

@ -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]

Binary file not shown.

View 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 ###

View 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

Binary file not shown.

View File

@ -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

View 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()

View File

@ -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"}

View File

@ -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")

View File

@ -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)}"
)
)

View File

@ -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

View File

@ -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

View File

@ -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 пользователя

View File

@ -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

View File

@ -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]

View File

@ -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]] = []

View File

@ -0,0 +1 @@

View 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()

View File

@ -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

View File

@ -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)
}
}

View 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

View File

@ -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
View 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()

View 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()

View File

@ -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

View File

@ -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

View File

@ -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
View 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;
}
```

View File

@ -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

View 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 # Для обработки изображений

View 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()

View 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()

View 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()

View 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()

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@ -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

View File

@ -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

Binary file not shown.

21
frontend/Dockerfile Normal file
View 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"]

Binary file not shown.

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}
}

View File

@ -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))}

View File

@ -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>
)
}
}

View File

@ -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>

View 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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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

Binary file not shown.

View File

@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
export function LazyBgLoader() {
useEffect(() => {
import("./lazy-background");
}, []);
return null;
}

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