Compare commits

..

25 Commits

Author SHA1 Message Date
@jcarymuhammedow
8f3079801b fixed some bugs 2026-05-02 14:28:50 +05:00
Jelaletdin12
684ab6917d added store to navbar 2026-04-30 23:17:44 +05:00
@jcarymuhammedow
6cdde96c61 added channels products 2026-04-30 16:15:29 +05:00
Jelaletdin12
9419ec0af0 added baha anyklmak, attributes 2026-04-30 10:44:08 +05:00
Jelaletdin12
cc89455967 added baha tassyklamak 2026-04-26 22:07:09 +05:00
@jcarymuhammedow
7060d05ae9 fixed gradient in product detail 2026-04-21 17:19:24 +05:00
@jcarymuhammedow
58287469ac changed story view image style 2026-04-20 18:03:44 +05:00
@jcarymuhammedow
29429f7139 changes satyjy link 2026-04-20 17:59:28 +05:00
@jcarymuhammedow
d92ec369a9 added story, flash sale, satyjy button 2026-04-20 17:51:02 +05:00
@jcarymuhammedow
76c819848b added sorting2 2026-04-18 12:10:38 +05:00
@jcarymuhammedow
a2298dfa9c added sorting 2026-04-18 11:05:39 +05:00
@jcarymuhammedow
6ef7aa3c47 fixed some bugs 2026-04-17 18:38:16 +05:00
@jcarymuhammedow
68382648a8 fixed some bugs: filter, detail 2026-04-16 14:08:57 +05:00
Jelaletdin12
808506832c addedlimit to server js 2026-04-13 20:47:11 +05:00
Jelaletdin12
aa3c333192 chanded categorycarousel to caotegoryslider 2026-04-13 20:21:29 +05:00
Jelaletdin12
ec8eb4fee4 changed icons on configurator 2026-04-01 11:05:55 +05:00
Jelaletdin12
33214002f2 updated category carousel ui 2026-03-31 22:04:17 +05:00
Jelaletdin12
696853e988 added category carousel 2026-03-30 22:08:32 +05:00
Jelaletdin12
3156e25068 adding new styles 2026-03-29 21:28:37 +05:00
Jelaletdin12
7c75205077 chanded api names 2026-03-27 23:01:33 +05:00
Jelaletdin12
7dc9a5fbc7 fixed spell error2 2026-03-27 22:41:34 +05:00
Jelaletdin12
99ad4fa1e0 fixed spell error 2026-03-27 22:39:34 +05:00
Jelaletdin12
c4ac669a09 added car configurator 2026-03-27 22:26:30 +05:00
@jcarymuhammedow
6ba07d7fef Merge branch 'main' of https://git.webulgam.com/nurmuhammet/mm.com.tm-frontend 2026-03-25 15:58:54 +05:00
@jcarymuhammedow
a7e31b380d make bigger some sizes 2026-03-25 15:58:21 +05:00
78 changed files with 7273 additions and 1962 deletions

10
ecosystem.config.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
apps: [
{
name: "frontend",
script: "server.js",
cwd: "/var/www/frontend",
env: { NODE_ENV: "production" }
}
]
};

28
fetch.js Normal file
View File

@@ -0,0 +1,28 @@
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import cors from "cors";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const DATA_PATH = path.join(__dirname, "public", "data.json");
app.use(cors());
app.use(express.json({ limit: "10mb" }));
app.get("/api/data", (req, res) => {
const raw = fs.readFileSync(DATA_PATH, "utf-8");
res.json(JSON.parse(raw));
});
app.post("/api/data", (req, res) => {
try {
fs.writeFileSync(DATA_PATH, JSON.stringify(req.body, null, 2), "utf-8");
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
app.listen(4000, () => console.log("API running on :4000"));

615
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"antd": "^5.22.7", "antd": "^5.22.7",
"express": "^5.2.1",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"i18next": "^24.2.1", "i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2", "i18next-browser-languagedetector": "^8.0.2",
@@ -1079,17 +1080,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"optional": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -1624,6 +1614,19 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
} }
}, },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -1897,6 +1900,30 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1946,12 +1973,14 @@
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"dev": true "dev": true
}, },
"node_modules/buffer-from": { "node_modules/bytes": {
"version": "1.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true, "license": "MIT",
"optional": true "engines": {
"node": ">= 0.8"
}
}, },
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
@@ -1975,7 +2004,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -1988,7 +2016,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6" "get-intrinsic": "^1.2.6"
@@ -2074,13 +2101,6 @@
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"dev": true "dev": true
}, },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"optional": true
},
"node_modules/compute-scroll-into-view": { "node_modules/compute-scroll-into-view": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
@@ -2092,6 +2112,28 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2106,6 +2148,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/copy-to-clipboard": { "node_modules/copy-to-clipboard": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
@@ -2201,10 +2252,10 @@
"peer": true "peer": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -2257,6 +2308,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -2273,7 +2333,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -2283,12 +2342,27 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.76", "version": "1.5.76",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
"integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==", "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==",
"dev": true "dev": true
}, },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.23.9", "version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
@@ -2358,7 +2432,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -2367,7 +2440,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -2403,7 +2475,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
}, },
@@ -2501,6 +2572,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2713,6 +2790,67 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2743,6 +2881,27 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2787,6 +2946,15 @@
"is-callable": "^1.1.3" "is-callable": "^1.1.3"
} }
}, },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.5.0", "version": "12.5.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.5.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.5.0.tgz",
@@ -2813,6 +2981,15 @@
} }
} }
}, },
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2831,7 +3008,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -2878,7 +3054,6 @@
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
@@ -2902,7 +3077,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0" "es-object-atoms": "^1.0.0"
@@ -2972,7 +3146,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -3032,7 +3205,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -3059,7 +3231,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
}, },
@@ -3075,6 +3246,26 @@
"void-elements": "3.1.0" "void-elements": "3.1.0"
} }
}, },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/i18next": { "node_modules/i18next": {
"version": "24.2.1", "version": "24.2.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
@@ -3114,6 +3305,22 @@
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.23.2"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3174,6 +3381,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/install": { "node_modules/install": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz",
@@ -3197,6 +3410,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3405,6 +3627,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -3732,11 +3960,56 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -3765,8 +4038,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"dev": true
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.8", "version": "3.3.8",
@@ -3792,6 +4064,15 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -6474,7 +6755,6 @@
"version": "1.13.3", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
"integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -6561,6 +6841,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6637,6 +6938,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6661,6 +6971,16 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true "dev": true
}, },
"node_modules/path-to-regexp": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6724,6 +7044,19 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6733,6 +7066,45 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/rc-cascader": { "node_modules/rc-cascader": {
"version": "3.30.0", "version": "3.30.0",
"resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.30.0.tgz", "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.30.0.tgz",
@@ -7595,6 +7967,22 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
@@ -7656,6 +8044,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sass-embedded": { "node_modules/sass-embedded": {
"version": "1.83.0", "version": "1.83.0",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.83.0.tgz", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.83.0.tgz",
@@ -8060,6 +8454,51 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
@@ -8111,6 +8550,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -8136,7 +8581,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.3",
@@ -8155,7 +8599,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.3"
@@ -8171,7 +8614,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -8189,7 +8631,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -8204,16 +8645,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8223,15 +8654,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-support": { "node_modules/statuses": {
"version": "0.5.21", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true, "license": "MIT",
"optional": true, "engines": {
"dependencies": { "node": ">= 0.8"
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
} }
}, },
"node_modules/string-convert": { "node_modules/string-convert": {
@@ -8425,6 +8854,15 @@
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="
}, },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8447,6 +8885,20 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typed-array-buffer": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -8539,12 +8991,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici-types": { "node_modules/unpipe": {
"version": "6.20.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true, "license": "MIT",
"optional": true "engines": {
"node": ">= 0.8"
}
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.1", "version": "1.1.1",
@@ -8599,6 +9053,15 @@
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"dev": true "dev": true
}, },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.0.7", "version": "6.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
@@ -8787,6 +9250,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -7,11 +7,12 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "node server.js"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"antd": "^5.22.7", "antd": "^5.22.7",
"express": "^5.2.1",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"i18next": "^24.2.1", "i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2", "i18next-browser-languagedetector": "^8.0.2",

1
public/data.json Normal file

File diff suppressed because one or more lines are too long

41
server.js Normal file
View File

@@ -0,0 +1,41 @@
import express from "express";
import fs from "fs";
import path from "path";
const app = express();
const port = 4173;
// API endpointleri
app.post("/frontend-api/data", express.json({ limit: '20mb' }), (req, res) => {
fs.writeFile(path.join(process.cwd(), "dist", "data.json"), JSON.stringify(req.body, null, 2), (err) => {
if (err) {
res.status(500).send(err.message);
} else {
res.status(200).json({ ok: true });
}
});
});
app.get("/frontend-api/data", (req, res) => {
fs.readFile(path.join(process.cwd(), "dist", "data.json"), "utf-8", (err, data) => {
if (err) {
res.status(500).send(err.message);
} else {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("Content-Type", "application/json");
res.send(data);
}
});
});
// Statik dosyaları sun ve diğer tüm istekleri index.html'e yönlendir
app.use(express.static(path.join(process.cwd(), "dist")));
app.get(/^(?!\/api).*/, (req, res) => {
res.sendFile(path.join(process.cwd(), "dist", "index.html"));
});
app.listen(port, () => {
console.log(`Sunucu http://localhost:${port} adresinde çalışıyor`);
});

View File

@@ -8,7 +8,10 @@ export const mediaApi = baseApi.injectEndpoints({
getBanners: builder.query({ getBanners: builder.query({
query: () => '/media/banners', query: () => '/media/banners',
}), }),
getStories: builder.query({
query: () => '/media/stories',
}),
}), }),
}); });
export const { useGetCarouselsQuery, useGetBannersQuery } = mediaApi; export const { useGetCarouselsQuery, useGetBannersQuery, useGetStoriesQuery } = mediaApi;

View File

@@ -5,16 +5,9 @@ export const brandsApi = baseApi.injectEndpoints({
getBrands: builder.query({ getBrands: builder.query({
query: (params = {}) => { query: (params = {}) => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params.type) { if (params.type) queryParams.append("type", params.type);
queryParams.append("type", params.type); if (params.page) queryParams.append("page", params.page);
} if (params.perPage) queryParams.append("perPage", params.perPage);
if (params.page) {
queryParams.append("page", params.page);
}
if (params.limit) {
queryParams.append("limit", params.limit);
}
const queryString = queryParams.toString(); const queryString = queryParams.toString();
return `/brands${queryString ? `?${queryString}` : ""}`; return `/brands${queryString ? `?${queryString}` : ""}`;
}, },
@@ -28,18 +21,19 @@ export const brandsApi = baseApi.injectEndpoints({
getBrandProducts: builder.query({ getBrandProducts: builder.query({
query: (params) => { query: (params) => {
if (typeof params === 'string' || typeof params === 'number') { if (typeof params === "string" || typeof params === "number") {
return `/brands/${params}/products`; return `/brands/${params}/products`;
} }
const { id, page = 1, limit } = params; const { id, page = 1, perPage = 12, sorting, min_price, max_price } = params;
let url = `/brands/${id}/products?page=${page}`; const urlParams = new URLSearchParams();
urlParams.append("page", page);
urlParams.append("perPage", perPage);
if (sorting) urlParams.append("sorting", sorting);
if (min_price) urlParams.append("min_price", min_price);
if (max_price) urlParams.append("max_price", max_price);
if (limit) { return `/brands/${id}/products?${urlParams.toString()}`;
url += `&limit=${limit}`;
}
return url;
}, },
transformResponse: (response) => ({ transformResponse: (response) => ({
data: response.data || response, data: response.data || response,

View File

@@ -5,16 +5,16 @@ export const categoriesApi = baseApi.injectEndpoints({
getCategories: builder.query({ getCategories: builder.query({
query: (type = "tree") => `/categories?type=${type}`, query: (type = "tree") => `/categories?type=${type}`,
}), }),
getCategoryProducts: builder.query({ getCategoryProducts: builder.query({
query: ({ categoryId, page = 1, limit, brands, min_price, max_price }) => { query: ({ categoryId, page = 1, perPage = 12, brands, min_price, max_price, sorting }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', page); params.append("page", page);
if (limit) params.append('limit', limit); params.append("perPage", perPage);
if (brands) params.append('brands', brands); if (brands) params.append("brands", brands);
if (min_price) params.append('min_price', min_price); if (min_price) params.append("min_price", min_price);
if (max_price) params.append('max_price', max_price); if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting);
return `categories/${categoryId}/products?${params.toString()}`; return `categories/${categoryId}/products?${params.toString()}`;
}, },
transformResponse: (response) => ({ transformResponse: (response) => ({
@@ -22,79 +22,105 @@ export const categoriesApi = baseApi.injectEndpoints({
pagination: response.pagination || {}, pagination: response.pagination || {},
}), }),
}), }),
getAllCategoryProducts: builder.query({ getAllCategoryProducts: builder.query({
async queryFn(category, queryApi, extraOptions, baseQuery) { async queryFn(category, _queryApi, _extraOptions, baseQuery) {
const fetchProducts = async (categoryId) => { const fetchProducts = async (categoryId) => {
const result = await baseQuery(`categories/${categoryId}/products`); const result = await baseQuery(`categories/${categoryId}/products`);
return result.data ? result.data.data : []; return result.data ? result.data.data : [];
}; };
let allProducts = await fetchProducts(category.id); let allProducts = await fetchProducts(category.id);
for (const child of category.children) { for (const child of category.children) {
const childProducts = await fetchProducts(child.id); const childProducts = await fetchProducts(child.id);
allProducts = [...allProducts, ...childProducts]; allProducts = [...allProducts, ...childProducts];
} }
return { data: allProducts }; return { data: allProducts };
}, },
}), }),
getAllCategoryProductsPaginated: builder.query({ getAllCategoryProductsPaginated: builder.query({
async queryFn( async queryFn(
{ category, page = 1, limit = 6, brands, min_price, max_price }, { category, page = 1, perPage = 12, brands, min_price, max_price, sorting },
queryApi, _queryApi,
extraOptions, _extraOptions,
baseQuery baseQuery
) { ) {
if (!category) return { data: [] }; if (!category) return { data: { data: [], pagination: { currentPage: 1, hasMorePages: false } } };
try { try {
const hasMoreByCategory = {};
const fetchProductsForPage = async (categoryIds, currentPage) => {
let allPageProducts = [];
const perCategoryLimit = Math.ceil(limit / categoryIds.length);
for (const categoryId of categoryIds) {
const params = new URLSearchParams();
params.append('page', currentPage);
params.append('limit', perCategoryLimit);
if (brands) params.append('brands', brands);
if (min_price) params.append('min_price', min_price);
if (max_price) params.append('max_price', max_price);
const result = await baseQuery(
`categories/${categoryId}/products?${params.toString()}`
);
if (result.data && result.data.data) {
allPageProducts = [...allPageProducts, ...result.data.data];
hasMoreByCategory[categoryId] = !!result.data.pagination.next_page_url;
}
}
return allPageProducts;
};
const categoryIds = [category.id]; const categoryIds = [category.id];
if (category.children && category.children.length > 0) { if (category.children?.length > 0) {
category.children.forEach((child) => categoryIds.push(child.id)); category.children.forEach((child) => categoryIds.push(child.id));
} }
const productsForPage = await fetchProductsForPage(categoryIds, page); // Tek category — direkt fetch, limit tam uygulanır
if (categoryIds.length === 1) {
const params = new URLSearchParams();
params.append("page", page);
params.append("perPage", perPage);
if (brands) params.append("brands", brands);
if (min_price) params.append("min_price", min_price);
if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting);
const hasMorePages = Object.values(hasMoreByCategory).some( const result = await baseQuery(
(hasMore) => hasMore `categories/${categoryIds[0]}/products?${params.toString()}`
); );
if (result.error) return { error: result.error };
return {
data: {
data: result.data?.data || [],
pagination: {
currentPage: page,
hasMorePages: !!result.data?.pagination?.next_page_url,
},
},
};
}
// Birden fazla category — paralel fetch, her biri tam limit ile
// Sonra client-side deduplicate + slice
const requests = categoryIds.map((categoryId) => {
const params = new URLSearchParams();
params.append("page", page);
params.append("perPage", perPage);
if (brands) params.append("brands", brands);
if (min_price) params.append("min_price", min_price);
if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting);
return baseQuery(`categories/${categoryId}/products?${params.toString()}`);
});
const results = await Promise.all(requests);
let allProducts = [];
let hasMorePages = false;
const seenIds = new Set();
for (const result of results) {
if (result.error) continue;
const items = result.data?.data || [];
for (const item of items) {
if (!seenIds.has(item.id)) {
seenIds.add(item.id);
allProducts.push(item);
}
}
if (result.data?.pagination?.next_page_url) {
hasMorePages = true;
}
}
return { return {
data: { data: {
data: productsForPage, data: allProducts,
pagination: { pagination: {
currentPage: page, currentPage: page,
hasMorePages: hasMorePages, hasMorePages,
}, },
}, },
}; };
@@ -103,11 +129,11 @@ export const categoriesApi = baseApi.injectEndpoints({
} }
}, },
}), }),
getProductById: builder.query({ getProductById: builder.query({
query: (productId) => `/products/${productId}`, query: (productId) => `/products/${productId}`,
}), }),
getRelatedProducts: builder.query({ getRelatedProducts: builder.query({
query: (productId) => `/products/${productId}/related`, query: (productId) => `/products/${productId}/related`,
}), }),

View File

@@ -0,0 +1,53 @@
import { baseApi } from "./baseApi";
export const channelsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getChannelProducts: builder.query({
query: (params) => {
// params: { channelId, page, limit, min_price, max_price, sorting }
const {
channelId,
page = 1,
perPage = 24,
min_price,
max_price,
sorting,
} = params;
const urlParams = new URLSearchParams();
urlParams.append("page", page);
urlParams.append("perPage", perPage);
if (min_price) urlParams.append("min_price", min_price);
if (max_price) urlParams.append("max_price", max_price);
if (sorting) urlParams.append("sorting", sorting);
return `/channels/${channelId}/products?${urlParams.toString()}`;
},
transformResponse: (response) => ({
data: response.data || response,
pagination: response.pagination || {},
}),
}),
getChannels: builder.query({
query: (params = {}) => {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append("page", params.page);
if (params.perPage) queryParams.append("perPage", params.perPage);
if (params.search) queryParams.append("search", params.search);
const queryString = queryParams.toString();
return `/channels${queryString ? `?${queryString}` : ""}`;
},
transformResponse: (response) => ({
data: response.data || response,
pagination: response.pagination || {},
}),
}),
}),
overrideExisting: true,
});
export const {
useGetChannelProductsQuery,
useLazyGetChannelProductsQuery,
useGetChannelsQuery,
useLazyGetChannelsQuery,
} = channelsApi;

View File

@@ -5,39 +5,35 @@ export const collectionsApi = baseApi.injectEndpoints({
getCollections: builder.query({ getCollections: builder.query({
query: () => `/collections`, query: () => `/collections`,
}), }),
getCollectionById: builder.query({ getCollectionById: builder.query({
query: (collectionId) => `/collections/${collectionId}`, query: (collectionId) => `/collections/${collectionId}`,
}), }),
getCollectionProducts: builder.query({ getCollectionProducts: builder.query({
query: (collectionId) => `/collections/${collectionId}/products`, query: (collectionId) => `/collections/${collectionId}/products`,
transformResponse: (response) => { transformResponse: (response) => ({
return { data: response.data || [],
data: response.data || [], isEmpty: !response.data || response.data.length === 0,
isEmpty: !response.data || response.data.length === 0, }),
};
},
}), }),
checkCollectionHasProducts: builder.query({ checkCollectionHasProducts: builder.query({
query: (collectionId) => `/collections/${collectionId}/products?limit=1`, query: (collectionId) => `/collections/${collectionId}/products`,
transformResponse: (response) => { transformResponse: (response) => ({
return { hasProducts: response.data && response.data.length > 0,
hasProducts: response.data && response.data.length > 0, }),
};
},
}), }),
getCollectionProductsPaginated: builder.query({ getCollectionProductsPaginated: builder.query({
query: ({ collectionId, page = 1, limit = 6, brands, min_price, max_price }) => { query: ({ collectionId, page = 1, perPage = 24, brands, min_price, max_price, sorting }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', page); params.append("page", page);
if (limit) params.append('limit', limit); params.append("perPage", perPage);
if (brands) params.append('brands', brands); if (brands) params.append("brands", brands);
if (min_price) params.append('min_price', min_price); if (min_price) params.append("min_price", min_price);
if (max_price) params.append('max_price', max_price); if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting); // undefined gelirse gönderme
return `/collections/${collectionId}/products?${params.toString()}`; return `/collections/${collectionId}/products?${params.toString()}`;
}, },
transformResponse: (response) => ({ transformResponse: (response) => ({

View File

@@ -15,6 +15,9 @@ export const filtersApi = baseApi.injectEndpoints({
if (params?.brand_id) { if (params?.brand_id) {
queryParams.append("brand_id", String(params.brand_id)) queryParams.append("brand_id", String(params.brand_id))
} }
if (params?.channel_id) {
queryParams.append("channel_id", String(params.channel_id))
}
return `/filters?${queryParams.toString()}` return `/filters?${queryParams.toString()}`
}, },
@@ -22,6 +25,7 @@ export const filtersApi = baseApi.injectEndpoints({
return { return {
categories: response.data?.categories || [], categories: response.data?.categories || [],
brands: response.data?.brands || [], brands: response.data?.brands || [],
channels: response.data?.channels || [],
} }
}, },
keepUnusedDataFor: 300, keepUnusedDataFor: 300,
@@ -40,7 +44,9 @@ export const filtersApi = baseApi.injectEndpoints({
if (queryArgs.brand_id) { if (queryArgs.brand_id) {
parts.push(`brd:${queryArgs.brand_id}`); parts.push(`brd:${queryArgs.brand_id}`);
} }
if (queryArgs.channel_id) {
parts.push(`chn:${queryArgs.channel_id}`);
}
return parts.length > 0 ? parts.join('|') : 'no-params'; return parts.length > 0 ? parts.join('|') : 'no-params';
}, },
@@ -66,7 +72,9 @@ export const filtersApi = baseApi.injectEndpoints({
if (arg.brand_id) { if (arg.brand_id) {
tags.push({ type: "Filters", id: `brd-${arg.brand_id}` }); tags.push({ type: "Filters", id: `brd-${arg.brand_id}` });
} }
if (arg.channel_id) {
tags.push({ type: "Filters", id: `chn-${arg.channel_id}` });
}
return tags; return tags;
}, },
}), }),

View File

@@ -0,0 +1,11 @@
import { baseApi } from "./baseApi";
export const flashSalesApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getFlashSales: builder.query({
query: () => "/flash-sales",
}),
}),
});
export const { useGetFlashSalesQuery } = flashSalesApi;

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/door.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/engine.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/floor.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
src/assets/hood.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/maincar.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/roof.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/trunk_flor.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/trunk_lid.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,109 @@
.storiesContainer {
width: 100%;
max-width: 1366px;
margin: 0 auto 24px;
padding: 0 0.75rem;
box-sizing: border-box;
@media screen and (max-width: 768px) {
padding: 0;
margin-bottom: 16px;
}
}
.storiesWrapper {
display: flex;
gap: 14px;
padding: 10px 4px;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
max-width: 1336px;
cursor: grab;
user-select: none;
&.dragging {
cursor: grabbing;
}
&::-webkit-scrollbar {
display: none;
}
}
.storyButton {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
background: none;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
&:active .storyAvatar {
transform: scale(0.9);
}
}
/* Gradient ring — conic-gradient, padding trick, temiz */
.storyAvatar {
position: relative;
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-sizing: border-box;
transition: transform 0.15s ease;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(#f44336, #e91e63, #ff9800, #f44336);
z-index: 0;
}
img {
position: relative;
z-index: 1;
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
background: #fff;
border: 2px solid #fff;
display: block;
box-sizing: border-box;
}
&.viewed::before {
background: #d0d0d0;
}
&.viewed img {
opacity: 0.5;
}
}
/* Badge/viewedIndicator tamamen kaldırıldı */
.storyLabel {
font-size: 11px;
color: #333;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 68px;
font-weight: 400;
transition: color 0.2s;
.storyButton:hover & {
color: #e91e63;
}
}

View File

@@ -0,0 +1,56 @@
import { useRef, useEffect } from "react";
export function useDragScroll() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let isDown = false;
let startX = 0;
let scrollLeft = 0;
const onMouseDown = (e) => {
isDown = true;
el.classList.add("dragging");
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
delete el.dataset.dragged;
};
const onMouseLeave = () => {
isDown = false;
el.classList.remove("dragging");
};
const onMouseUp = () => {
isDown = false;
el.classList.remove("dragging");
setTimeout(() => delete el.dataset.dragged, 0);
};
const onMouseMove = (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - el.offsetLeft;
const walk = (x - startX) * 1.2;
if (Math.abs(walk) > 5) el.dataset.dragged = "true";
el.scrollLeft = scrollLeft - walk;
};
el.addEventListener("mousedown", onMouseDown);
el.addEventListener("mouseleave", onMouseLeave);
el.addEventListener("mouseup", onMouseUp);
el.addEventListener("mousemove", onMouseMove);
return () => {
el.removeEventListener("mousedown", onMouseDown);
el.removeEventListener("mouseleave", onMouseLeave);
el.removeEventListener("mouseup", onMouseUp);
el.removeEventListener("mousemove", onMouseMove);
};
}, []);
return ref;
}

View File

@@ -1,197 +1,152 @@
import React, { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { import { Autoplay, Thumbs, Pagination, Navigation, Mousewheel, FreeMode } from "swiper/modules";
Autoplay, import { Skeleton } from "antd";
Thumbs,
Pagination,
Navigation,
Mousewheel,
FreeMode,
} from "swiper/modules";
import "swiper/css"; import "swiper/css";
import "swiper/css/pagination"; import "swiper/css/pagination";
import "swiper/css/thumbs"; import "swiper/css/thumbs";
import "swiper/css/navigation"; import "swiper/css/navigation";
import styles from "./Banner.module.scss"; import styles from "./Banner.module.scss";
import { useGetCarouselsQuery } from "../../app/api/bannersApi.js"; import storiesStyles from "./Stories.module.scss";
import { Skeleton } from "antd"; import { useGetCarouselsQuery, useGetStoriesQuery } from "../../app/api/bannersApi.js";
import StoryViewer from "../StoryViewer/StoryViewer";
import { useDragScroll } from "./hook/useDragScroll.js";
function Carousel() { function Carousel() {
const { data, isLoading, isError } = useGetCarouselsQuery(); const { data: carouselData, isLoading, isError } = useGetCarouselsQuery();
const { data: storiesData, isLoading: isStoriesLoading, isError: isStoriesError } = useGetStoriesQuery();
const [thumbsSwiper, setThumbsSwiper] = useState(null); const [thumbsSwiper, setThumbsSwiper] = useState(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(true); const [isAnimating, setIsAnimating] = useState(true);
const thumbSliderRef = useRef(null); const [selectedStoryIndex, setSelectedStoryIndex] = useState(null);
const [viewedStoryIds, setViewedStoryIds] = useState(new Set());
const storiesScrollRef = useDragScroll();
useEffect(() => { useEffect(() => {
setIsAnimating(false); setIsAnimating(false);
setTimeout(() => setIsAnimating(true), 50); const timer = setTimeout(() => setIsAnimating(true), 50);
return () => clearTimeout(timer);
}, [activeIndex]); }, [activeIndex]);
const updateScrollPosition = (targetIndex) => {
if (!thumbSliderRef.current) return;
const container = thumbSliderRef.current.querySelector(".swiper-wrapper");
const slideHeight = container.children[0]?.offsetHeight || 0;
const spaceBetween = 15;
const scrollPosition = targetIndex * (slideHeight + spaceBetween);
container.parentNode.scrollTop = scrollPosition;
};
const handleSlideChange = (swiper) => { const handleSlideChange = (swiper) => {
const newActiveIndex = swiper.realIndex; setActiveIndex(swiper.realIndex);
setActiveIndex(newActiveIndex); };
if (thumbsSwiper?.slides) { const handleImageClick = (link) => {
const slidesPerView = 4; if (link) window.open(link, "_blank", "noopener,noreferrer");
let targetIndex = newActiveIndex - Math.floor(slidesPerView / 2); };
targetIndex = Math.max(
0,
Math.min(targetIndex, thumbsSwiper.slides.length - slidesPerView)
);
thumbsSwiper.slideTo(targetIndex, 300); const handleStoryViewed = (storyIndex) => {
updateScrollPosition(targetIndex); const storyId = storiesData?.data[storyIndex]?.id;
if (storyId) {
setViewedStoryIds((prev) => new Set(prev).add(storyId));
} }
}; };
// Handler for clicking on carousel images const handleStoryClick = (index) => {
const handleImageClick = (link) => { if (storiesScrollRef.current?.dataset.dragged) return;
if (link) { setSelectedStoryIndex(index);
window.open(link, '_blank', 'noopener,noreferrer');
}
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className={styles.carouselContainer}> <div className={styles.carouselContainer}>
{/* Main slider skeleton */} <div className={styles.mainSliderSkeleton}>
<div className={`${styles.mainSlider} skeleton-main-slider`}> <Skeleton.Image active className={styles.fullWidthSkeleton} />
<Skeleton.Image active={true} className="main-skeleton-image" />
<style jsx>{`
.skeleton-main-slider {
width: 100%;
aspect-ratio: 16/9;
position: relative;
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
}
.main-skeleton-image {
width: 100% !important;
height: 100% !important;
}
`}</style>
</div>
{/* Thumbnail Slider skeleton */}
<div className={`${styles.thumbSlider} skeleton-thumb-slider`}>
{[...Array(5)].map((_, index) => (
<div
key={index}
className={`${styles.thumbWrapper} skeleton-thumb`}
>
<Skeleton.Image active={true} />
<style jsx>{`
.skeleton-thumb-slider {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
@media screen and (max-width:767px){
display: none;}
}
.skeleton-thumb {
width: 100%;
height: 100%;
margin-bottom: 10px;
border-radius: 4px;
overflow: hidden;
}
`}</style>
</div>
))}
</div> </div>
</div> </div>
); );
} }
if (isError || !data || !data.data || data.data.length === 0) { if (isError || !carouselData?.data?.length) return null;
return <div>No images available</div>;
}
return ( return (
<div className={styles.carouselContainer}> <>
{/* Main Slider */} {!isStoriesLoading && !isStoriesError && storiesData?.data?.length > 0 && (
<Swiper <div className={storiesStyles.storiesContainer}>
modules={[Thumbs, Pagination, Navigation, Autoplay]} <div className={storiesStyles.storiesWrapper} ref={storiesScrollRef}>
thumbs={{ swiper: thumbsSwiper }} {storiesData.data.map((story, index) => {
autoplay={{ delay: 3000, disableOnInteraction: false }} const isViewed = viewedStoryIds.has(story.id);
loop={true} return (
pagination={{
clickable: true, <button
}} key={story.id}
navigation={true} className={storiesStyles.storyButton}
className={styles.mainSlider} onClick={() => handleStoryClick(index)}
onSlideChange={handleSlideChange} >
> <div className={`${storiesStyles.storyAvatar} ${isViewed ? storiesStyles.viewed : ""}`}>
{data.data.map((item) => ( <img src={story.thumbnail || story.photo} alt={story.title} />
<SwiperSlide key={item.id}> </div>
<div <span className={storiesStyles.storyLabel}>{story.title}</span>
className={styles.imageWrapper} </button>
onClick={() => handleImageClick(item.link)} );
style={{ cursor: item.link ? 'pointer' : 'default' }} })}
> </div>
<img </div>
src={item.image} )}
alt={item.title || `Carousel Image ${item.id}`}
/>
</div>
</SwiperSlide>
))}
</Swiper>
{/* Thumbnail Slider */} {selectedStoryIndex !== null && (
<Swiper <StoryViewer
ref={thumbSliderRef} stories={storiesData.data}
modules={[Thumbs, Autoplay, FreeMode, Mousewheel]} initialIndex={selectedStoryIndex}
onSwiper={setThumbsSwiper} onClose={() => setSelectedStoryIndex(null)}
autoplay={{ delay: 3000 }} onStoryViewed={handleStoryViewed}
slidesPerView={4} />
spaceBetween={10} )}
direction="vertical"
watchSlidesProgress={true} <div className={styles.carouselContainer}>
slideToClickedSlide={true} <Swiper
cssMode={true} modules={[Thumbs, Pagination, Navigation, Autoplay]}
loop={false} thumbs={{ swiper: thumbsSwiper && !thumbsSwiper.destroyed ? thumbsSwiper : null }}
allowTouchMove={true} autoplay={{ delay: 3000, disableOnInteraction: false }}
className={styles.thumbSlider} loop={true}
> pagination={{ clickable: true }}
{data.data.map((item, index) => ( navigation={true}
<SwiperSlide key={item.id}> className={styles.mainSlider}
<div onSlideChange={handleSlideChange}
className={`${styles.thumbWrapper} ${ >
index === activeIndex ? styles.active : "" {carouselData.data.map((item) => (
}`} <SwiperSlide key={item.id}>
> <div
<img className={styles.imageWrapper}
src={item.thumbnail} onClick={() => handleImageClick(item.link)}
alt={item.title || `Thumbnail ${index + 1}`} style={{ cursor: item.link ? "pointer" : "default" }}
/> >
{index === activeIndex && isAnimating && ( <img src={item.image} alt={item.title || "Banner"} />
<> </div>
<div className={styles.progressBarImg}></div> </SwiperSlide>
<div className={styles.progressBar}></div> ))}
</> </Swiper>
)}
</div> <Swiper
</SwiperSlide> modules={[Thumbs, FreeMode, Mousewheel]}
))} onSwiper={setThumbsSwiper}
</Swiper> slidesPerView={4}
</div> spaceBetween={10}
direction="vertical"
watchSlidesProgress={true}
className={styles.thumbSlider}
>
{carouselData.data.map((item, index) => (
<SwiperSlide key={item.id}>
<div className={`${styles.thumbWrapper} ${index === activeIndex ? styles.active : ""}`}>
<img src={item.thumbnail} alt={`Thumb ${index}`} />
{index === activeIndex && isAnimating && (
<div className={styles.progressContainer}>
<div className={styles.progressBarImg}></div>
<div className={styles.progressBar}></div>
</div>
)}
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</>
); );
} }

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from "react";
import styles from "./Carconfigurator.module.scss";
import maincar from "../../assets/maincar.webp";
import imgRoof from "../../assets/roof.jpg";
import imgHood from "../../assets/hood.jpg";
import imgFloor from "../../assets/floor.jpg";
import imgDoor from "../../assets/door.jpg";
import imgTrunkLid from "../../assets/trunk_lid.jpg";
import imgTrunkFloor from "../../assets/trunk_flor.jpg";
import imgEngine from "../../assets/engine.jpg";
import imgArchInter from "../../assets/arch_inter_side.jpg";
import imgArchStreet from "../../assets/arch_street_side.jpg";
import imgPlasticFender from "../../assets/plastik_fender_liner_4.jpg";
// ─── Zone image map ───────────────────────────────────────────────────────────
const ZONE_IMAGES = {
roof: imgRoof,
hood: imgHood,
floor: imgFloor,
doors: imgDoor,
trunk_lid: imgTrunkLid,
trunk_floor: imgTrunkFloor,
engine: imgEngine,
arch_interior: imgArchInter,
arch_street: imgArchStreet,
fender_liner: imgPlasticFender,
wheel: imgArchStreet,
};
const DOTS = [
{ id: "roof", x: 44, y: 22 },
{ id: "trunk_lid", x: 80, y: 27 },
{ id: "trunk_floor", x: 68, y: 46 },
{ id: "arch_interior", x: 75, y: 43 },
{ id: "fender_liner", x: 73, y: 51 },
{ id: "arch_street", x: 47, y: 69 },
{ id: "engine", x: 40, y: 59 },
{ id: "floor", x: 58, y: 61 },
{ id: "doors", x: 74, y: 80 },
{ id: "hood", x: 17, y: 52 },
{ id: "wheel", x: 42, y: 82 },
];
// ─── Product List ─────────────────────────────────────────────────────────────
function ProductList({ zone, pkg, bodyType, appData }) {
if (!appData) return <p className={styles.emptyMsg}>Загрузка...</p>;
const zoneInfo = appData.zones[zone];
const products = zoneInfo?.products?.[bodyType]?.[pkg] || [];
const total = products.reduce((s, p) => s + p.price * p.qty, 0);
const zoneImage = ZONE_IMAGES[zone];
return (
<div className={styles.productList}>
{zoneImage ? (
<div className={styles.zoneImageWrapper}>
<img src={zoneImage} alt={zoneInfo?.label} className={styles.zoneImage} />
<span className={styles.zoneImageLabel}>{zoneInfo?.label}</span>
</div>
) : (
<h3 className={styles.zoneTitle}>{zoneInfo?.label}</h3>
)}
{products.length === 0 && (
<p className={styles.emptyMsg}>Нет товаров для данной зоны / пакета.</p>
)}
{products.map((p, i) => (
<div key={i} className={styles.productRow}>
<span className={styles.productName}>{p.name}</span>
<span className={styles.productCalc}>
<b>{p.price.toLocaleString("ru")} m</b>
{" × "}
{p.qty} {p.unit}
{" = "}
<b className={styles.subtotal}>
{(p.price * p.qty).toLocaleString("ru")} m
</b>
</span>
</div>
))}
{products.length > 0 && (
<div className={styles.totalRow}>
Итого: <strong>{total.toLocaleString("ru")} m</strong>
</div>
)}
{/* <div className={styles.actionButtons}>
<button className={styles.btnBuy}>Купить</button>
<button className={styles.btnView}>Смотреть</button>
</div> */}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function CarConfigurator() {
const [appData, setAppData] = useState(null);
const [selectedBody, setSelectedBody]= useState(null);
const [selectedPkg, setSelectedPkg] = useState(null);
const [activeZone, setActiveZone] = useState("roof");
const [tooltip, setTooltip] = useState(null);
// Load data.json — served from /public/data.json, readable by all users
useEffect(() => {
fetch("/frontend-api/data")
.then((r) => r.json())
.then((data) => {
setAppData(data);
setSelectedBody(data.bodyTypes[0]?.id || "sedan");
setSelectedPkg(data.packages[0] || "Максимум");
})
.catch(console.error);
}, []);
if (!appData) {
return (
<div className={styles.wrapper}>
<p className={styles.emptyMsg}>Загрузка данных...</p>
</div>
);
}
return (
<div className={styles.wrapper}>
{/* ── Body Type Bar — TOP ──────────────────────────────── */}
<div className={styles.bodyBar}>
<p className={styles.sectionLabel}>Тип кузова</p>
<div className={styles.bodyGrid}>
{appData.bodyTypes.map((b) => (
<button
key={b.id}
className={`${styles.bodyCard} ${selectedBody === b.id ? styles.active : ""}`}
onClick={() => setSelectedBody(b.id)}
>
<span className={styles.bodyIcon}>
{b.image ? <img src={b.image} alt={b.label} /> : b.icon}
</span>
<span className={styles.bodyLabel}>{b.label}</span>
{selectedBody === b.id && <span className={styles.activeDot} />}
</button>
))}
</div>
</div>
{/* ── Main ─────────────────────────────────────────────── */}
<div className={styles.main}>
{/* Car visual */}
<div className={styles.carWrapper}>
<img
className={styles.carImage}
src={maincar}
alt="Автомобиль"
draggable={false}
/>
{DOTS.map((dot) => (
<button
key={dot.id}
className={`${styles.dot} ${activeZone === dot.id ? styles.dotActive : ""}`}
style={{ left: `${dot.x}%`, top: `${dot.y}%` }}
onClick={() => setActiveZone(dot.id)}
onMouseEnter={() => setTooltip(dot)}
onMouseLeave={() => setTooltip(null)}
aria-label={appData.zones[dot.id]?.label}
>
<span className={styles.dotRipple} />
</button>
))}
{tooltip && (
<div
className={styles.tooltip}
style={{ left: `${tooltip.x}%`, top: `${tooltip.y - 8}%` }}
>
{appData.zones[tooltip.id]?.label ?? tooltip.id}
</div>
)}
</div>
{/* Right panel */}
<div className={styles.panel}>
<div className={styles.pkgTabs}>
{appData.packages.map((p) => (
<button
key={p}
className={`${styles.pkgTab} ${selectedPkg === p ? styles.pkgActive : ""}`}
onClick={() => setSelectedPkg(p)}
>
{p}
</button>
))}
</div>
<div className={styles.productGridContainer}>
<ProductList
zone={activeZone}
pkg={selectedPkg}
bodyType={selectedBody}
appData={appData}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,397 @@
// ── Variables ─────────────────────────────────────────────────
$orange: #f26522;
$orange-dark: #d4551a;
$bg: #f5f5f5;
$white: #ffffff;
$border: #e0e0e0;
$text: #1a1a1a;
$muted: #888;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 16px;
$transition: 0.2s ease;
// ── Wrapper ───────────────────────────────────────────────────
.wrapper {
font-family: "Segoe UI", sans-serif;
background: $bg;
border-radius: $radius-lg;
max-width: 1336px;
margin: 0 auto;
color: $text;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
// ── Body Bar (TOP) ────────────────────────────────────────────
.bodyBar {
background: $white;
border-radius: $radius-lg;
border: 1px solid $border;
padding: 14px 20px;
}
.sectionLabel {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $muted;
margin: 0 0 10px;
}
// ── Main Layout ───────────────────────────────────────────────
.main {
display: grid;
grid-template-columns: 1fr 380px;
gap: 20px;
align-items: start;
@media (max-width: 860px) {
grid-template-columns: 1fr;
}
}
// ── Car Wrapper ───────────────────────────────────────────────
.carWrapper {
position: relative;
background: $white;
border-radius: $radius-lg;
overflow: hidden;
border: 1px solid $border;
}
.carImage {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
user-select: none;
pointer-events: none;
}
// ── Dots ──────────────────────────────────────────────────────
.dot {
position: absolute;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: $orange;
border: 3px solid $white;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: transform $transition, background $transition;
padding: 0;
&:hover {
transform: translate(-50%, -50%) scale(1.25);
background: $orange-dark;
}
&.dotActive {
background: $white;
border-color: $orange;
box-shadow: 0 0 0 3px $orange;
.dotRipple {
animation: ripple 1.2s ease-out infinite;
}
}
}
.dotRipple {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid $orange;
opacity: 0;
}
@keyframes ripple {
0% { inset: -4px; opacity: 0.8; }
100% { inset: -16px; opacity: 0; }
}
// ── Tooltip ───────────────────────────────────────────────────
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.78);
color: $white;
font-size: 0.72rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
margin-top: -8px;
z-index: 10;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(0, 0, 0, 0.78);
}
}
// ── Right Panel ───────────────────────────────────────────────
.panel {
background: $white;
border-radius: $radius-lg;
border: 1px solid $border;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
max-height: 90vh;
overflow-y: auto;
}
// ── Body Grid ─────────────────────────────────────────────────
.bodyGrid {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 6px;
@media (max-width: 860px) {
grid-template-columns: repeat(5, 1fr);
}
@media (max-width: 480px) {
grid-template-columns: repeat(3, 1fr);
}
}
.bodyCard {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
background: $bg;
border: 2px solid $border;
border-radius: $radius-md;
cursor: pointer;
transition: border-color $transition, box-shadow $transition;
&:hover {
border-color: $orange;
box-shadow: 0 2px 8px rgba($orange, 0.15);
}
&.active {
border-color: $orange;
background: lighten($orange, 45%);
}
}
.bodyIcon {
font-size: 1.3rem;
line-height: 1;
}
.bodyIcon {
max-height: 60px;
max-width: 60px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.productGridContainer {
max-height: 400px; /* Yüksekliği buradan ayarlayabilirsiniz */
overflow-y: auto;
padding-right: 10px; /* Kaydırma çubuğu için boşluk */
}
.bodyLabel {
font-size: 0.6rem;
font-weight: 600;
text-align: center;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.activeDot {
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: $orange;
}
// ── Package Tabs ──────────────────────────────────────────────
.pkgTabs {
display: flex;
gap: 4px;
border-bottom: 2px solid $border;
padding-bottom: 12px;
}
.pkgTab {
flex: 1;
padding: 6px 4px;
font-size: 0.72rem;
font-weight: 700;
border: none;
background: none;
color: $muted;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -14px;
transition: color $transition, border-color $transition;
&:hover { color: $orange; }
&.pkgActive {
color: $orange;
border-bottom-color: $orange;
}
}
// ── Zone Image ────────────────────────────────────────────────
.zoneImageWrapper {
position: relative;
border-radius: $radius-md;
overflow: hidden;
height: 140px;
background: #000;
}
.zoneImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0.9;
transition: opacity $transition;
&:hover { opacity: 1; }
}
.zoneImageLabel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 10px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
color: $white;
font-size: 0.78rem;
font-weight: 700;
}
// ── Product List ──────────────────────────────────────────────
.productList {
display: flex;
flex-direction: column;
gap: 8px;
}
.zoneTitle {
font-size: 1rem;
font-weight: 700;
margin: 0 0 4px;
color: $text;
}
.emptyMsg {
font-size: 0.78rem;
color: $muted;
font-style: italic;
margin: 0;
}
.productRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
background: $bg;
border-radius: $radius-sm;
border-left: 3px solid $orange;
}
.productName {
font-size: 0.78rem;
color: $text;
font-weight: 500;
}
.productCalc {
font-size: 0.74rem;
color: $muted;
}
.subtotal {
color: $orange-dark;
}
.totalRow {
font-size: 0.85rem;
font-weight: 700;
color: $text;
padding-top: 4px;
border-top: 1px solid $border;
text-align: right;
strong {
color: $orange;
font-size: 1rem;
}
}
// ── Action Buttons ────────────────────────────────────────────
.actionButtons {
display: flex;
gap: 10px;
margin-top: 4px;
}
.btnBuy {
flex: 1;
padding: 10px;
background: $orange;
color: $white;
border: none;
border-radius: $radius-sm;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
transition: background $transition;
&:hover { background: $orange-dark; }
}
.btnView {
flex: 1;
padding: 10px;
background: $white;
color: $orange;
border: 2px solid $orange;
border-radius: $radius-sm;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
transition: background $transition, color $transition;
&:hover {
background: $orange;
color: $white;
}
}

View File

@@ -0,0 +1,80 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, FreeMode } from "swiper/modules";
import { useGetCategoriesQuery } from "../../app/api/categories.js";
import styles from "./CategorySlider.module.scss";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
const CategoryCarousel = () => {
const { data, isLoading } = useGetCategoriesQuery("tree");
const navigate = useNavigate();
const mainCategories =
data?.data?.filter((cat) => cat.parent_id === null) ?? [];
const handleCardClick = (id) => {
navigate(`/category/${id}`);
};
if (isLoading || mainCategories.length === 0) return null;
return (
<div className={styles.wrapper}>
<Swiper
modules={[Navigation, FreeMode]}
navigation={{
prevEl: `.${styles.prev}`,
nextEl: `.${styles.next}`,
}}
freeMode={{ enabled: true, momentum: true, momentumRatio: 0.5 }}
slidesPerView="auto"
spaceBetween={12}
grabCursor={true}
className={styles.swiper}
>
{mainCategories.map((cat) => {
const thumb =
cat.media?.[0]?.images_400x400 ??
cat.media?.[0]?.thumbnail ??
null;
return (
<SwiperSlide key={cat.id} className={styles.slide}>
<div
className={styles.card}
onClick={() => handleCardClick(cat.id)}
>
<div className={styles.imgWrap}>
{thumb ? (
<img
src={thumb}
alt={cat.name}
loading="lazy"
draggable={false}
/>
) : (
<div className={styles.placeholder} />
)}
</div>
<span className={styles.name}>{cat.name}</span>
</div>
</SwiperSlide>
);
})}
</Swiper>
<button className={`${styles.arrow} ${styles.prev}`} aria-label="Scroll left">
</button>
<button className={`${styles.arrow} ${styles.next}`} aria-label="Scroll right">
</button>
</div>
);
};
export default CategoryCarousel;

View File

@@ -0,0 +1,151 @@
.wrapper {
position: relative;
display: flex;
align-items: center;
gap: 4px;
margin: 20px 0 24px 0;
}
/* ── Swiper overrides ── */
.swiper {
flex: 1;
overflow: hidden;
padding: 4px 2px 8px 2px !important;
:global(.swiper-button-disabled) {
opacity: 0.3;
pointer-events: none;
}
}
.slide {
width: 200px !important;
height: auto;
}
/* ── Card ── */
.card {
width: 100%;
height: 100%;
border-radius: 12px;
background: #f5f5f5;
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border: 1px solid #d24141;
.imgWrap img {
transform: scale(1.04);
}
}
}
/* ── Image area ── */
.imgWrap {
width: 100%;
// aspect-ratio: 4 / 3;
overflow: hidden;
background: #ebebeb;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
pointer-events: none;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0 0%, #d0d0d0 100%);
}
/* ── Name label — sabit yükseklik ── */
.name {
padding: 10px 12px 12px 12px;
font-size: 16px;
font-weight: 600;
color: #222;
text-align: center;
line-height: 1.4;
background: #fff;
/* 2 satır = 2 × 13px × 1.4 + padding = ~60px — tüm kartlar eşit */
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Arrow buttons ── */
.arrow {
position: absolute;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #ddd;
background: #fff;
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #444;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
padding: 0;
z-index: 1;
&:hover {
background: #d24141;
border-color: #d24141;
color: #fff;
box-shadow: 0 2px 8px rgba(210, 65, 65, 0.35);
}
&.prev {
margin-right: 4px;
}
&.next {
margin-left: 4px;
right: 0;
}
}
/* ── Responsive ── */
@media (max-width: 768px) {
.arrow {
display: none;
}
.slide {
width: 140px !important;
}
}
@media (max-width: 480px) {
.slide {
width: 120px !important;
}
.card {
border-radius: 10px;
}
.name {
font-size: 12px;
padding: 8px 8px 10px 8px;
min-height: 52px;
}
}

View File

@@ -1,77 +1,116 @@
// DropdownMenu.module.scss
.dropdownContainer { .dropdownContainer {
position: relative;
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
display: none; display: none;
} }
} }
// ---- TRIGGER BUTTON ----
.navButton { .navButton {
display: flex; display: flex;
gap: 5px;
border: none;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-left: 0.875rem;
padding-right: 0.875rem;
justify-content: center;
align-items: center; align-items: center;
gap: 6px;
border: none;
padding: 0.25rem 0.875rem;
border-radius: 0.5rem; border-radius: 0.5rem;
height: 2.5rem; height: 2.5rem;
font-size: 0.875rem; font-size: 16px;
font-weight: 600;
color: #4b5563; color: #4b5563;
background-color: transparent; background-color: transparent;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s, color 0.15s;
position: relative;
z-index: 999;
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
} }
}
.dropdownWrapper { &.navButtonActive {
position: relative; background-color: #e63946;
} color: #ffffff;
.dropdownPanel { svg {
position: absolute; color: #ffffff;
top: 100%; }
margin-top: 8px;
z-index: 50;
display: flex;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
width: 1366px;
padding: 0 1.375rem;
}
.categoriesList {
flex: 1;
max-height: 500px;
overflow-y: auto;
border-right: 1px solid #ebe7eb;
padding: 20px;
display: flex;
flex-direction: column;
gap: 5px;
// &::-webkit-scrollbar {
// width: 6px;
// }
&::-webkit-scrollbar-track {
background: #e5e7eb;
} }
}
// ---- OVERLAY ----
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 998;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// ---- WRAPPER + ANIMATION ----
.dropdownWrapper {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 999;
animation: slideDown 0.18s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ---- PANEL SHELL ----
.dropdownPanel {
display: flex;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
width: 1336px;
max-height: 520px;
max-width: calc(100vw - 32px);
}
// ---- LEFT LIST ----
.categoriesList {
width: 270px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
padding: 10px 0;
max-height: 520px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f9fafb;
}
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: #d1d5db; background: #d1d5db;
} border-radius: 10px;
.title {
&:hover { &:hover {
color: #888888; background: #9ca3af;
}
&:active {
color: #888888;
} }
} }
} }
@@ -79,156 +118,169 @@
.categoryItem { .categoryItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
padding: 6px; padding: 9px 16px;
font-size: 16px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; color: #000;
border: 1px solid #3615371a; transition: background-color 0.12s, color 0.12s;
border-radius: 6px;
&:hover { &:hover {
background-color: #f9fafb; background-color: #f3f4f6;
color: #e63946;
} }
&.active { &.active {
background-color: #f3f4f6; background-color: #f3f4f6;
} color: #e63946;
.icon { .title {
font-size: 14px; font-weight: 600;
}
.title {
font-size: 14px;
&:hover {
color: #888888;
} }
} }
} }
.title {
font-size: 14px;
}
.chevron {
color: #9ca3af;
flex-shrink: 0;
}
// ---- RIGHT CONTENT PANEL ----
.contentPanel { .contentPanel {
flex: 3; flex: 1;
padding: 16px; padding: 20px 24px;
max-height: 400px; max-height: 520px;
overflow-y: hidden; overflow-y: auto;
background: #ffffff;
// &::-webkit-scrollbar { &::-webkit-scrollbar {
// width: 6px; width: 6px;
// } }
&::-webkit-scrollbar-track {
&::-webkit-scrollbar-track { background: #f9fafb;
background: #e5e7eb;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: #d1d5db; background: #d1d5db;
// border-radius: 3px; border-radius: 10px;
}
.title {
cursor: pointer;
color: #361517;
font-size: 24px;
font-weight: 600;
&:hover { &:hover {
color: #888888; background: #9ca3af;
} }
} }
} }
.column {
display: flex; .panelTitle {
flex-direction: column; font-size: 20px;
flex: 2; font-weight: 700;
text-align: left; color: #111827;
margin-bottom: 16px;
cursor: pointer;
display: inline-block;
&:hover {
color: #e63946;
}
}
// COLUMN GRID MODE
// SONRA — column layout (iyi, masonry gibi akar)
.columnsGrid {
columns: 250px auto;
column-gap: 24px;
}
.columnSection {
break-inside: avoid;
margin-bottom: 20px;
display: inline-block; // break-inside'ın çalışması için zorunlu
width: 100%;
} }
.sectionTitle { .sectionTitle {
font-size: 16px;
font-weight: 500;
margin-bottom: 12px;
color: #361517;
cursor: pointer;
&:hover {
color: #888888;
}
}
.subcategoryList {
margin-bottom: 24px;
display: flex;
}
.subcategoryItem {
font-size: 14px; font-size: 14px;
color: #361517; font-weight: 800;
padding: 4px 0; color: #111827;
margin-bottom: 6px;
cursor: pointer; cursor: pointer;
transition: color 0.2s;
&:hover { &:hover {
color: #888888; color: #e63946;
} }
} }
.subCategoriesContainer { .leafItem {
display: flex; display: block;
flex-direction: column; font-size: 14px;
max-height: 360px; color: #4b5563;
overflow-y: auto; padding: 3px 0;
}
.nestedCategoryContainer:last-child {
margin-bottom: 16px;
}
.nestedCategoryContainer {
margin-bottom: 4px;
}
.nestedCategoryItem {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: color 0.12s;
&:hover {
color: #e63946;
}
}
// FLAT LIST MODE
.flatList {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-content: flex-start;
}
.flatListBordered {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.flatItem {
font-size: 14px;
color: #4b5563;
cursor: pointer;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid #e5e7eb;
transition: background-color 0.12s, color 0.12s;
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
} color: #111827;
.categoryLabel {
flex: 1;
display: flex;
align-items: center;
}
.title {
font-size: 14px;
} }
} }
.expandButton, .navButtonLoading {
.navigateButton { opacity: 0.7;
cursor: wait;
.categoryIcon {
opacity: 0.4;
}
}
.loadingDots {
display: flex; display: flex;
gap: 3px;
align-items: center; align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
background-color: transparent;
border: none;
cursor: pointer;
&:hover { span {
background-color: #e5e7eb; width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
animation: dotPulse 1.2s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
} }
} }
.nestedChildren { @keyframes dotPulse {
margin-top: 4px; 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
} 40% { transform: scale(1); opacity: 1; }
}
.noSubcategories {
color: #6b7280;
font-style: italic;
}

View File

@@ -1,83 +1,62 @@
// DropdownMenu.jsx
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import styles from "./DropdownMenu.module.scss"; import styles from "./DropdownMenu.module.scss";
import { useGetCategoriesQuery } from "../../app/api/categories"; import { useGetCategoriesQuery } from "../../app/api/categories";
import { CategoryIcon } from "../Icons"; import { CategoryIcon } from "../Icons";
import { ChevronRight, ChevronDown } from "lucide-react"; // Assuming you have access to lucide-react or similar
const NestedCategory = ({ const ContentPanel = ({ category, onSelect, onClose }) => {
category, if (!category) return null;
level = 0,
handleCategorySelect,
closeDropdown,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const hasChildren = category.children && category.children.length > 0;
const handleClick = (e) => { const children = category.children || [];
e.stopPropagation(); const withChildren = children.filter((c) => c.children?.length > 0);
if (hasChildren) { const withoutChildren = children.filter((c) => !c.children?.length);
setIsExpanded(!isExpanded);
} else {
handleCategorySelect(category);
closeDropdown();
}
};
const handleDirectNavigation = (e) => { const allColumns = [
e.stopPropagation(); ...withChildren,
handleCategorySelect(category); ...withoutChildren.map((c) => ({ ...c, children: [] })),
closeDropdown(); ];
};
return ( return (
<div <div className={styles.contentPanel}>
className={styles.nestedCategoryContainer} <h2
style={{ paddingLeft: `${level * 16}px` }} className={styles.panelTitle}
> onClick={() => {
<div className={styles.nestedCategoryItem} onClick={handleClick}> onSelect(category);
<div className={styles.categoryLabel}> onClose();
<span className={styles.title}>{category.name}</span> }}
</div> >
{category.name}
</h2>
{hasChildren && ( {allColumns.length > 0 && (
<button <div className={styles.columnsGrid}>
className={styles.expandButton} {allColumns.map((sub) => (
onClick={(e) => { <div key={sub.id} className={styles.columnSection}>
e.stopPropagation(); <div
setIsExpanded(!isExpanded); className={styles.sectionTitle}
}} onClick={() => {
> onSelect(sub);
{isExpanded ? ( onClose();
<ChevronDown size={16} /> }}
) : ( >
<ChevronRight size={16} /> {sub.name}
)} </div>
</button> {sub.children?.map((leaf) => (
)} <span
key={leaf.id}
{hasChildren && ( className={styles.leafItem}
<button onClick={() => {
className={styles.navigateButton} onSelect(leaf);
onClick={handleDirectNavigation} onClose();
title="Go to category" }}
> >
{leaf.name}
</button> </span>
)} ))}
</div> </div>
{hasChildren && isExpanded && (
<div className={styles.nestedChildren}>
{category.children.map((child) => (
<NestedCategory
key={child.id}
category={child}
level={level + 1}
handleCategorySelect={handleCategorySelect}
closeDropdown={closeDropdown}
/>
))} ))}
</div> </div>
)} )}
@@ -89,113 +68,85 @@ const DropdownMenu = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
const { const {
data: categoriesData, data: categoriesData,
isLoading, isLoading,
error, error,
} = useGetCategoriesQuery("tree"); } = useGetCategoriesQuery("tree");
const categories = categoriesData?.data || []; const categories = categoriesData?.data || [];
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeMainCategory, setActiveMainCategory] = useState(null); const [activeCategory, setActiveCategory] = useState(null);
useEffect(() => { useEffect(() => {
if (categories.length > 0) { if (categories.length > 0 && !activeCategory) {
const defaultCategory = setActiveCategory(categories[0]);
categories.find((cat) => cat.name === "Aýallar üçin") || categories[0];
setActiveMainCategory(defaultCategory);
} }
}, [categories]); }, [categories]);
const handleToggle = () => { useEffect(() => {
setIsOpen(!isOpen); const handler = (e) => {
}; if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleMouseLeave = () => { const handleSelect = (category) => {
if (categories.length > 0) {
const defaultCategory =
categories.find((cat) => cat.name === "Aýallar üçin") || categories[0];
setActiveMainCategory(defaultCategory);
}
};
const handleCategorySelect = (category) => {
navigate(`/category/${category.id}`, { state: { category } }); navigate(`/category/${category.id}`, { state: { category } });
setIsOpen(false); setIsOpen(false);
}; };
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading categories</div>;
return ( return (
<div className={styles.dropdownContainer} ref={dropdownRef}> <div className={styles.dropdownContainer} ref={dropdownRef}>
<button onClick={handleToggle} className={styles.navButton}> <button
onClick={() => setIsOpen((p) => !p)}
className={`${styles.navButton} ${isOpen ? styles.navButtonActive : ""}`}
aria-expanded={isOpen}
>
<CategoryIcon /> <CategoryIcon />
{t("navbar.category")} {isLoading
? <div className={styles.loadingDots}><span/><span/><span/></div>
: t("navbar.category")
}
</button> </button>
{isOpen && ( {isOpen && (
<div className={styles.dropdownWrapper}> <>
<div className={styles.dropdownPanel} onMouseLeave={handleMouseLeave}> <div className={styles.overlay} onClick={() => setIsOpen(false)} />
<div className={styles.categoriesList}>
{categories.map((category) => (
<div
key={category.id}
className={`${styles.categoryItem} ${
activeMainCategory?.id === category.id ? styles.active : ""
}`}
onMouseEnter={() => setActiveMainCategory(category)}
onClick={() => handleCategorySelect(category)}
>
<span className={styles.title}>{category.name}</span>
</div>
))}
</div>
{activeMainCategory && ( <div className={styles.dropdownWrapper}>
<div className={styles.contentPanel}> <div className={styles.dropdownPanel}>
<h2 <div className={styles.categoriesList}>
onClick={() => handleCategorySelect(activeMainCategory)} {categories.map((cat) => (
className={styles.title} <div
> key={cat.id}
{activeMainCategory.name} className={`${styles.categoryItem} ${
</h2> activeCategory?.id === cat.id ? styles.active : ""
}`}
<div className={styles.subCategoriesContainer}> onMouseEnter={() => setActiveCategory(cat)}
{activeMainCategory.children && onClick={() => handleSelect(cat)}
activeMainCategory.children.length > 0 ? ( >
activeMainCategory.children.map((subcategory) => ( <span className={styles.title}>{cat.name}</span>
<NestedCategory {cat.children?.length > 0 && (
key={subcategory.id} <ChevronRight size={14} className={styles.chevron} />
category={subcategory} )}
handleCategorySelect={handleCategorySelect} </div>
closeDropdown={() => setIsOpen(false)} ))}
/>
))
) : (
<div className={styles.noSubcategories}>
{/* No subcategories available */}
</div>
)}
</div>
</div> </div>
)}
<ContentPanel
category={activeCategory}
onSelect={handleSelect}
onClose={() => setIsOpen(false)}
/>
</div>
</div> </div>
</div> </>
)} )}
</div> </div>
); );

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import styles from "./Checkout.module.scss"; import styles from "./Checkout.module.scss";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
usePlaceOrderMutation, usePlaceOrderMutation,
@@ -9,202 +8,145 @@ import {
} from "../../app/api/orderApi"; } from "../../app/api/orderApi";
import { useGetLocationsQuery } from "../../app/api/locationApi"; import { useGetLocationsQuery } from "../../app/api/locationApi";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const useDeviceType = () => { const useDeviceType = () => {
const [deviceType, setDeviceType] = useState("desktop"); const [deviceType, setDeviceType] = useState("desktop");
useEffect(() => { useEffect(() => {
const userAgent = navigator.userAgent; setDeviceType(
if (/Mobi|Android/i.test(userAgent)) { /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "desktop",
setDeviceType("mobile"); );
} else {
setDeviceType("desktop");
}
}, []); }, []);
return deviceType; return deviceType;
}; };
const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceOrder }) => { const Checkout = ({
cartItems,
shippingPrice,
productIds,
onBackToCart,
onPlaceOrder,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
customer_name: "", customer_name: "",
customer_phone: "", customer_phone: "+993 ",
customer_address: "", customer_address: "",
deliveryAddress: "null",
payment_type_id: "", payment_type_id: "",
notes: "", notes: "",
region: "", region: "",
}); });
const [selectedAddress, setSelectedAddress] = useState(null); const [selectedAddress, setSelectedAddress] = useState(null);
const [placeOrder, { isLoading: isPlacingOrder }] = usePlaceOrderMutation(); const [placeOrder] = usePlaceOrderMutation();
const { data: orderTimes = {} } = useGetOrderTimesQuery();
const { data: orderPayments = [] } = useGetOrderPaymentsQuery(); const { data: orderPayments = [] } = useGetOrderPaymentsQuery();
const { data: locationsData } = useGetLocationsQuery(); const { data: locationsData } = useGetLocationsQuery();
const deviceType = useDeviceType(); const deviceType = useDeviceType();
// Sepetteki tüm ürünlerin fiyatı 0 mı?
const allItemsZeroPrice = cartItems?.every((item) =>
isPriceZero(item.product?.price_amount),
);
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
if (name === "customer_phone") { if (name === "customer_phone") {
// Always keep the +993 prefix
const prefix = "+993 "; const prefix = "+993 ";
if (value.length < prefix.length) return;
// If user is trying to delete the prefix, prevent it
if (value.length < prefix.length) { const digits = value
return; // Don't update state, keep the current value .substring(prefix.length)
} .replace(/\D/g, "")
.substring(0, 8);
// Extract only the digits after the prefix let formatted = prefix + digits.substring(0, 2);
const inputWithoutPrefix = value.substring(prefix.length).replace(/\D/g, ""); if (digits.length > 2) formatted += " " + digits.substring(2);
// Limit to 8 digits max (Turkmenistan mobile number format) setFormData((prev) => ({ ...prev, [name]: formatted }));
const limitedDigits = inputWithoutPrefix.substring(0, 8);
// Format with space after first 2 digits
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
setFormData((prev) => ({
...prev,
[name]: formattedPhone,
}));
} else { } else {
setFormData((prev) => ({ setFormData((prev) => ({ ...prev, [name]: value }));
...prev,
[name]: value,
}));
} }
}; };
const handleAddressSelect = (value) => { const handleAddressSelect = (value) => {
setSelectedAddress(value); setSelectedAddress(value);
const selectedLocation = locationsData?.data?.find( const selectedLocation = locationsData?.data?.find((l) => l.name === value);
(location) => location.name === value
);
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
address: value, address: value,
region: selectedLocation ? selectedLocation.region : "", region: selectedLocation?.region || "",
})); }));
}; };
// Initialize phone with prefix
useEffect(() => {
setFormData(prev => ({
...prev,
customer_phone: "+993 "
}));
}, []);
const formatPhoneNumber = (phoneNumber) => {
// Remove the +993 prefix and any spaces
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handleClearAddress = () => { const handleClearAddress = () => {
setSelectedAddress(null); setSelectedAddress(null);
setFormData((prev) => ({ setFormData((prev) => ({ ...prev, address: "" }));
...prev,
address: "",
}));
}; };
const handleFocus = (event) => { const handleFocus = (e) =>
event.target.scrollIntoView({ e.target.scrollIntoView({ behavior: "smooth", block: "center" });
behavior: "smooth",
block: "center", const formatPhoneNumber = (phone) =>
}); phone.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const getOrderData = () => { const getOrderData = () => {
// Validation checks
if ( if (
!formData.customer_name || !formData.customer_name ||
!formData.customer_phone || !formData.customer_phone ||
!formData.customer_address || !formData.customer_address ||
!formData.payment_type_id !formData.payment_type_id
) { ) {
console.error("Missing required fields");
alert("Please fill in all required fields"); alert("Please fill in all required fields");
return null; return null;
} }
// Set default values for delivery const currentDate = new Date().toISOString().split("T")[0];
const currentDate = new Date().toISOString().split('T')[0];
const defaultTimeSlot = {
date: currentDate,
hour: "12:00-14:00" // Default time slot
};
// Prepare data in the format expected by the API
return { return {
customer_name: formData.customer_name, customer_name: formData.customer_name,
customer_phone: formatPhoneNumber(formData.customer_phone), customer_phone: formatPhoneNumber(formData.customer_phone),
customer_address: formData.customer_address, customer_address: formData.customer_address,
shipping_method: "standard", // Default to standard shipping shipping_method: "standard",
payment_type_id: formData.payment_type_id, payment_type_id: formData.payment_type_id,
delivery_time: defaultTimeSlot.hour, delivery_time: "12:00-14:00",
delivery_at: defaultTimeSlot.date, delivery_at: currentDate,
region: formData.region || "", region: formData.region || "",
notes: formData.notes || "", notes: formData.notes || "",
// Add shipping price and product IDs
shipping_price: shippingPrice, shipping_price: shippingPrice,
product_ids: productIds // Array of product IDs [1, 3, 4, etc.] product_ids: productIds,
}; };
}; };
// Create the place order function
const handlePlaceOrder = async () => { const handlePlaceOrder = async () => {
const orderDetails = getOrderData(); const orderDetails = getOrderData();
if (!orderDetails) return false; if (!orderDetails) return false;
try { try {
const response = await placeOrder(orderDetails).unwrap(); await placeOrder(orderDetails).unwrap();
console.log("Order placed successfully:", response);
window.location.href = "/orders"; window.location.href = "/orders";
return true; return true;
} catch (error) { } catch (error) {
console.error("Failed to place order:", error); const isHtmlResponse =
if (
error.data && error.data &&
typeof error.data === "string" && typeof error.data === "string" &&
error.data.includes("<!doctype html>") error.data.includes("<!doctype html>");
) {
console.error( alert(
"Server returned HTML instead of a proper API response" isHtmlResponse
); ? "There was a problem with the server. Please try again later."
alert( : "Failed to place order. Please check your information and try again.",
"There was a problem with the server. Please try again later or contact support." );
);
} else {
alert(
"Failed to place order. Please check your information and try again."
);
}
return false; return false;
} }
}; };
// Expose the function to parent component via callback
useEffect(() => { useEffect(() => {
if (onPlaceOrder) { if (onPlaceOrder) onPlaceOrder(handlePlaceOrder);
onPlaceOrder(handlePlaceOrder);
}
}, [formData, shippingPrice, productIds]); }, [formData, shippingPrice, productIds]);
return ( return (
<div className={styles.checkoutContainer}> <div className={styles.checkoutContainer}>
<h2>{t("cart.basket")} ({cartItems?.length || 0})</h2> {/* <h2>{t("cart.basket")} ({cartItems?.length || 0})</h2> */}
<div className={styles.formSection}> <div className={styles.formSection}>
<div className={styles.paymentOptions}> <div className={styles.paymentOptions}>
<h3>{t("checkout.paymentMethod")}:</h3> <h3>{t("checkout.paymentMethod")}:</h3>
@@ -221,22 +163,22 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
<label <label
htmlFor={`payment${payment.id}`} htmlFor={`payment${payment.id}`}
className={styles.customRadio} className={styles.customRadio}
></label> />
<div <div
className={styles.text} className={styles.text}
onClick={() => { onClick={() =>
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
payment_type_id: String(payment.id), payment_type_id: String(payment.id),
})); }))
}} }
> >
<span className={styles.optionTitle}>{payment.name}</span> <span className={styles.optionTitle}>{payment.name}</span>
<span className={styles.optionDesc}> {/* <span className={styles.optionDesc}>
{payment.name === "Nagt" {payment.name === "Nagt"
? t("checkout.payment_in_cash_upon_delivery_of_the_order") ? t("checkout.payment_in_cash_upon_delivery_of_the_order")
: t("checkout.payment_by_card")} : t("checkout.payment_by_card")}
</span> </span> */}
</div> </div>
</div> </div>
))} ))}
@@ -256,7 +198,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
onFocus={handleFocus} onFocus={handleFocus}
/> />
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>{t("checkout.telephone")}*</label> <label>{t("checkout.telephone")}*</label>
<input <input
@@ -270,7 +211,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
/> />
</div> </div>
</div> </div>
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>{t("checkout.moreAboutYourAddress")}*</label> <label>{t("checkout.moreAboutYourAddress")}*</label>
@@ -283,7 +223,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
onFocus={handleFocus} onFocus={handleFocus}
/> />
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>{t("checkout.note")}</label> <label>{t("checkout.note")}</label>
<input <input
@@ -301,22 +240,17 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
<ul> <ul>
<li> <li>
{t( {t(
"checkout.Delivery_is_carried_out_in_the_cities_of_Ashgabat_Buzmein_and_Anau" "checkout.Delivery_is_carried_out_in_the_cities_of_Ashgabat_Buzmein_and_Anau",
)} )}
</li> </li>
<li> <li>
{t( {t(
"checkout.The_minimum_order_amount_must_be_at_least_50_manat_for_orders_over_150_manat_delivery_is_free" "checkout.After_you_place_an_order_on_the_website_the_operator_will_call_you_to_confirm_the_order_for_regular_customers_confirmation_is_carried_out_automatically_at_their_request",
)} )}
</li> </li>
<li> <li>
{t( {t(
"checkout.After_you_place_an_order_on_the_website_the_operator_will_call_you_to_confirm_the_order_for_regular_customers_confirmation_is_carried_out_automatically_at_their_request" "checkout.Payment_is_made_after_you_check_and_accept_the_order_The_amount_of_your_payment_is_indicated_on_the_delivery_persons_payment_document_Payment_is_made_in_cash_and_by_card_in_national_currency_Accepted_and_paid_goods_are_not_subject_to_return",
)}
</li>
<li>
{t(
"checkout.Payment_is_made_after_you_check_and_accept_the_order_The_amount_of_your_payment_is_indicated_on_the_delivery_persons_payment_document_Payment_is_made_in_cash_and_by_card_in_national_currency_Accepted_and_paid_goods_are_not_subject_to_return"
)} )}
</li> </li>
</ul> </ul>
@@ -326,4 +260,4 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
); );
}; };
export default Checkout; export default Checkout;

View File

@@ -0,0 +1,206 @@
// ── Design tokens ──────────────────────────────────────────────────────────
$flash-red: #e53935;
$flash-dark-red: #c62828;
$flash-accent: #ff6b6b;
$flash-yellow: #FFD54F;
$white: #fff;
// ── Section wrapper ────────────────────────────────────────────────────────
.flashSales {
margin: 28px 0;
border-radius: 12px;
// overflow: hidden;
background: $white;
box-shadow: 0 4px 24px rgba(229, 57, 53, 0.15);
border: 1.5px solid rgba(229, 57, 53, 0.18);
}
// ── Gradient header ────────────────────────────────────────────────────────
.header {
padding: 14px 20px;
background: linear-gradient(120deg, $flash-red 0%, $flash-dark-red 100%);
gap: 12px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
@media (max-width: 480px) {
padding: 10px 14px;
gap: 8px;
}
}
// ── Left: label ────────────────────────────────────────────────────────────
.flashLabel {
flex-shrink: 0;
}
.zapWrapper {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.18);
border-radius: 8px;
padding: 4px;
}
.zapIcon {
color: $flash-yellow;
filter: drop-shadow(0 0 6px rgba(255, 213, 79, 0.8));
animation: zapPulse 1s ease-in-out infinite alternate;
display: block;
}
@keyframes zapPulse {
from { opacity: 0.75; transform: scale(1); }
to { opacity: 1; transform: scale(1.2); }
}
.flashText {
font-size: 1.35rem;
font-weight: 900;
color: $white;
letter-spacing: 2.5px;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
white-space: nowrap;
@media (max-width: 480px) {
font-size: 1.05rem;
letter-spacing: 1.5px;
}
}
.saleTitle {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
@media (max-width: 480px) {
display: none;
}
}
// ── Right: timer ───────────────────────────────────────────────────────────
.timerWrapper {
flex-shrink: 0;
}
.timerLabel {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
font-weight: 500;
@media (max-width: 600px) {
display: none;
}
}
.timerBlock {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 7px;
padding: 5px 11px;
min-width: 44px;
backdrop-filter: blur(4px);
@media (max-width: 480px) {
min-width: 36px;
padding: 4px 8px;
}
}
.timerDigit {
font-size: 1.25rem;
font-weight: 800;
color: $white;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
@media (max-width: 480px) {
font-size: 1rem;
}
}
.timerUnit {
font-size: 0.58rem;
color: rgba(255, 255, 255, 0.65);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
margin-top: 2px;
}
.timerSep {
color: $white;
font-size: 1.2rem;
font-weight: 800;
line-height: 1;
margin-bottom: 10px; // optical alignment with digit row
user-select: none;
}
// ── Swiper container ───────────────────────────────────────────────────────
.swiperWrapper {
padding: 16px 24px;
background: #fff8f8;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
@media (max-width: 480px) {
padding: 12px 10px;
}
// Navigation arrows
:global(.swiper-button-next),
:global(.swiper-button-prev) {
color: $flash-red !important;
background: $white;
border-radius: 50%;
width: 34px;
height: 34px;
box-shadow: 0 2px 10px rgba(229, 57, 53, 0.25);
transition: background 0.2s, color 0.2s;
&::after {
font-size: 13px;
font-weight: 900;
}
&:hover {
background: $flash-red !important;
color: $white !important;
}
}
:global(.swiper-button-disabled) {
opacity: 0.3;
pointer-events: none;
}
}
.swiper {
padding: 6px 2px 10px !important;
}
.slide {
height: auto;
display: flex;
align-items: stretch;
// Make ProductCard fill the slide height
> * {
// height: 100%;
// min-height: 100%;
// max-height: 100%;
display: flex;
flex-direction: column;
}
}

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import { Zap } from "lucide-react";
import { Flex } from "antd";
import { useGetFlashSalesQuery } from "../../app/api/flashSalesApi";
import ProductCard from "../ProductCard";
import styles from "./FlashSales.module.scss";
import { useTranslation } from "react-i18next";
const parseTime = (timeStr) => {
if (!timeStr) return { hours: "00", minutes: "00", seconds: "00" };
const parts = timeStr.split(":");
return {
hours: parts[0] || "00",
minutes: parts[1] || "00",
seconds: parts[2] || "00",
};
};
const FlashSales = () => {
const { t } = useTranslation();
const { data, isLoading, isError } = useGetFlashSalesQuery();
const [timers, setTimers] = useState([]);
const getTimeLeft = (end) => {
const endTime = new Date(end).getTime();
const now = new Date().getTime();
let diff = endTime - now;
if (diff <= 0) return "00:00:00";
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
diff = diff % (1000 * 60 * 60 * 24);
const hours = String(Math.floor(diff / (1000 * 60 * 60))).padStart(2, "0");
const minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, "0");
const seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, "0");
if (days > 0) {
return `${days}g ${hours}:${minutes}:${seconds}`;
}
return `${hours}:${minutes}:${seconds}`;
};
useEffect(() => {
if (!data?.data) return;
const updateTimers = () => {
setTimers(data.data.map((flashSale) => getTimeLeft(flashSale.ends_at)));
};
updateTimers();
const interval = setInterval(updateTimers, 1000);
return () => clearInterval(interval);
}, [data]);
if (isLoading || isError || !data?.data?.length) return null;
return (
<div>
{data.data.map((flashSale, idx) => {
// Timer parse
let days = 0, hours = "00", minutes = "00", seconds = "00";
if (timers[idx]) {
const match = timers[idx].match(/(?:(\d+)g )?(\d{2}):(\d{2}):(\d{2})/);
if (match) {
days = match[1] ? Number(match[1]) : 0;
hours = match[2];
minutes = match[3];
seconds = match[4];
}
}
return (
<section className={styles.flashSales} key={flashSale.id}>
{/* ── Header ── */}
<Flex
align="center"
justify="space-between"
wrap="wrap"
className={styles.header}
>
{/* Left: icon + title */}
<Flex align="center" gap={10} className={styles.flashLabel}>
<span className={styles.zapWrapper}>
<Zap size={22} className={styles.zapIcon} />
</span>
<span className={styles.flashText}>{t("flashSales.flash_sale")}</span>
{flashSale.title && (
<span className={styles.saleTitle}>{flashSale.title}</span>
)}
</Flex>
{/* Right: countdown timer */}
<Flex align="center" gap={8} className={styles.timerWrapper}>
<span className={styles.timerLabel}>{t("flashSales.ends_in")}</span>
<Flex align="center" gap={4}>
{days > 0 && (
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{days}</span>
<span className={styles.timerUnit}>{t("flashSales.day")}</span>
</div>
)}
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{hours}</span>
<span className={styles.timerUnit}>{t("flashSales.hour")}</span>
</div>
<span className={styles.timerSep}>:</span>
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{minutes}</span>
<span className={styles.timerUnit}>{t("flashSales.minute")}</span>
</div>
<span className={styles.timerSep}>:</span>
<div className={styles.timerBlock}>
<span className={styles.timerDigit}>{seconds}</span>
<span className={styles.timerUnit}>{t("flashSales.second")}</span>
</div>
</Flex>
</Flex>
</Flex>
{/* ── Products Carousel ── */}
<div className={styles.swiperWrapper}>
<Swiper
modules={[Navigation]}
navigation
slidesPerView={4}
spaceBetween={16}
breakpoints={{
0: { slidesPerView: 1.5, spaceBetween: 10 },
480: { slidesPerView: 2.2, spaceBetween: 12 },
768: { slidesPerView: 3, spaceBetween: 14 },
1024: { slidesPerView: 4, spaceBetween: 16 },
}}
className={styles.swiper}
>
{flashSale.products.map((product) => (
<SwiperSlide key={product.id} className={styles.slide}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</div>
</section>
);
})}
</div>
);
};
export default FlashSales;

View File

@@ -94,7 +94,6 @@ const Footer = () => {
/> />
</a> </a>
</div> </div>
<img src={apk} alt="Download APK" className={styles.appLogo} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,55 @@
.container {
max-width: 1336px;
margin: 20px auto;
width: 100%;
@media screen and (max-width: 1023px) {
margin: 10px auto;
}
}
.brandsSwiper {
padding-bottom: 8px;
}
.brandSlide {
width: auto;
}
.brandCard {
width: 122px;
height: 50px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 8px;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
.logoFallback {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
@media screen and (max-width: 768px) {
.brandCard {
width: 100px;
}
}

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useGetBrandsQuery } from '../../app/api/brandsApi';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay } from 'swiper/modules';
import 'swiper/css';
import styles from './HomeBrands.module.scss';
import { Logo } from '../Icons';
const HomeBrands = () => {
const navigate = useNavigate();
// Fetch brands.
const { data: brandsData, isLoading } = useGetBrandsQuery({ limit: 100 });
if (isLoading || !brandsData || brandsData.length === 0) return null;
return (
<div className={styles.container}>
<Swiper
modules={[Autoplay]}
spaceBetween={12}
slidesPerView={'auto'}
slidesPerGroup={2}
loop={true}
autoplay={{
delay: 3000,
disableOnInteraction: false,
}}
className={styles.brandsSwiper}
>
{brandsData.map((brand) => (
<SwiperSlide key={brand.id} className={styles.brandSlide}>
<div
className={styles.brandCard}
onClick={() => navigate(`/brands/${brand.id}`)}
>
{brand.media?.[0]?.thumbnail || brand.media?.[0]?.images_800x800 || brand.logo ? (
<img
src={
brand.media?.[0]?.thumbnail ||
brand.media?.[0]?.images_800x800 ||
brand.logo
}
alt={brand.name}
onError={(e) => {
e.target.style.display = "none";
e.target.nextSibling.style.display = "flex";
}}
/>
) : (
<div className={styles.logoFallback}>
<Logo width={40} height={40} />
</div>
)}
<div className={styles.logoFallback} style={{ display: "none" }}>
<Logo width={40} height={40} />
</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
export default HomeBrands;

View File

@@ -199,6 +199,7 @@ export const OrderIcon = () => (
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 28.35 28.35" viewBox="0 0 28.35 28.35"
data-v-5c1608dd="" data-v-5c1608dd=""
> >
<path <path
d="M24.4,16a2.37,2.37,0,0,0-.94-.59V14.22h0v-2.7h0V9a2.27,2.27,0,0,0-.29-1.11L21.72,5.33a2.27,2.27,0,0,0-2-1.15H11A2.27,2.27,0,0,0,9,5.33L7.52,7.91A2.38,2.38,0,0,0,7.23,9v2.5h0v2h0v.81H4.68a1.46,1.46,0,0,0-1.45,1.46v6.86a1.45,1.45,0,0,0,1.45,1.45H7.2a1.46,1.46,0,0,0,1.28-.78h1.68a2.69,2.69,0,0,1,.57.06l3.55.71a4.09,4.09,0,0,0,.85.09,4.24,4.24,0,0,0,.94-.11l4.64-1,3.65-3.5a2.43,2.43,0,0,0,0-3.49Zm-7.17-1.14-.06,0-3.89-.63a4.34,4.34,0,0,0-2.88.55l-1.75.8V11.52a1,1,0,0,1,1-1H21a1,1,0,0,1,1,1v2h0v1.87a2.45,2.45,0,0,0-.94.5L19,17.68c0-.11,0-.23,0-.36A2.64,2.64,0,0,0,17.23,14.89ZM20.49,6,21.93,8.6A.78.78,0,0,1,22,9v.29a2.51,2.51,0,0,0-1-.23h-5V5.59h3.7A.86.86,0,0,1,20.49,6ZM8.76,8.6,10.2,6A.86.86,0,0,1,11,5.59h3.69V9.08h-5a2.51,2.51,0,0,0-1,.23V9A.78.78,0,0,1,8.76,8.6Zm-1.53,14s0,0,0,0H4.68a0,0,0,0,1,0,0V15.78a0,0,0,0,1,0,0H7.2s0,0,0,0v6.86ZM23.39,18.5,20,21.73l-4.26,1a2.85,2.85,0,0,1-1.2,0L11,22a4.8,4.8,0,0,0-.85-.08H8.65V17.14L11,16.06l.08,0a2.87,2.87,0,0,1,2-.38l3.75.6a1.21,1.21,0,0,1,.76,1.08c0,.44,0,1.18-1.77,1.18H14.54a.7.7,0,0,0-.7.71.7.7,0,0,0,.7.7h4L22,17A1,1,0,0,1,23.4,17a1,1,0,0,1,.3.74A1,1,0,0,1,23.39,18.5Z" d="M24.4,16a2.37,2.37,0,0,0-.94-.59V14.22h0v-2.7h0V9a2.27,2.27,0,0,0-.29-1.11L21.72,5.33a2.27,2.27,0,0,0-2-1.15H11A2.27,2.27,0,0,0,9,5.33L7.52,7.91A2.38,2.38,0,0,0,7.23,9v2.5h0v2h0v.81H4.68a1.46,1.46,0,0,0-1.45,1.46v6.86a1.45,1.45,0,0,0,1.45,1.45H7.2a1.46,1.46,0,0,0,1.28-.78h1.68a2.69,2.69,0,0,1,.57.06l3.55.71a4.09,4.09,0,0,0,.85.09,4.24,4.24,0,0,0,.94-.11l4.64-1,3.65-3.5a2.43,2.43,0,0,0,0-3.49Zm-7.17-1.14-.06,0-3.89-.63a4.34,4.34,0,0,0-2.88.55l-1.75.8V11.52a1,1,0,0,1,1-1H21a1,1,0,0,1,1,1v2h0v1.87a2.45,2.45,0,0,0-.94.5L19,17.68c0-.11,0-.23,0-.36A2.64,2.64,0,0,0,17.23,14.89ZM20.49,6,21.93,8.6A.78.78,0,0,1,22,9v.29a2.51,2.51,0,0,0-1-.23h-5V5.59h3.7A.86.86,0,0,1,20.49,6ZM8.76,8.6,10.2,6A.86.86,0,0,1,11,5.59h3.69V9.08h-5a2.51,2.51,0,0,0-1,.23V9A.78.78,0,0,1,8.76,8.6Zm-1.53,14s0,0,0,0H4.68a0,0,0,0,1,0,0V15.78a0,0,0,0,1,0,0H7.2s0,0,0,0v6.86ZM23.39,18.5,20,21.73l-4.26,1a2.85,2.85,0,0,1-1.2,0L11,22a4.8,4.8,0,0,0-.85-.08H8.65V17.14L11,16.06l.08,0a2.87,2.87,0,0,1,2-.38l3.75.6a1.21,1.21,0,0,1,.76,1.08c0,.44,0,1.18-1.77,1.18H14.54a.7.7,0,0,0-.7.71.7.7,0,0,0,.7.7h4L22,17A1,1,0,0,1,23.4,17a1,1,0,0,1,.3.74A1,1,0,0,1,23.39,18.5Z"
@@ -206,7 +207,22 @@ export const OrderIcon = () => (
></path> ></path>
</svg> </svg>
); );
export const StoreIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="#4b5563"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width={16}
height={16}
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
);
export const CategoryIcon = () => ( export const CategoryIcon = () => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -220,7 +236,7 @@ export const CategoryIcon = () => (
height={20} height={20}
> >
<path <path
fill="#4b5563" fill="currentColor"
d="M30 20c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 27 17h-7c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 17 20v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 20 30h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 30 27v-7Zm-15 0c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 12 17H5c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 2 20v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 5 30h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 15 27v-7Zm13 0v7a.997.997 0 0 1-1 1h-7a.997.997 0 0 1-1-1v-7a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm-15 0v7a.997.997 0 0 1-1 1H5a.997.997 0 0 1-1-1v-7a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm2-15c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 12 2H5c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 2 5v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 5 15h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 15 12V5Zm15 0c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 27 2h-7c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 17 5v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 20 15h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 30 12V5ZM13 5v7a.997.997 0 0 1-1 1H5a.997.997 0 0 1-1-1V5a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm15 0v7a.997.997 0 0 1-1 1h-7a.997.997 0 0 1-1-1V5a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Z" d="M30 20c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 27 17h-7c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 17 20v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 20 30h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 30 27v-7Zm-15 0c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 12 17H5c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 2 20v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 5 30h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 15 27v-7Zm13 0v7a.997.997 0 0 1-1 1h-7a.997.997 0 0 1-1-1v-7a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm-15 0v7a.997.997 0 0 1-1 1H5a.997.997 0 0 1-1-1v-7a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm2-15c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 12 2H5c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 2 5v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 5 15h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 15 12V5Zm15 0c0-.796-.316-1.559-.879-2.121A2.996 2.996 0 0 0 27 2h-7c-.796 0-1.559.316-2.121.879A2.996 2.996 0 0 0 17 5v7c0 .796.316 1.559.879 2.121A2.996 2.996 0 0 0 20 15h7c.796 0 1.559-.316 2.121-.879A2.996 2.996 0 0 0 30 12V5ZM13 5v7a.997.997 0 0 1-1 1H5a.997.997 0 0 1-1-1V5a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Zm15 0v7a.997.997 0 0 1-1 1h-7a.997.997 0 0 1-1-1V5a.997.997 0 0 1 1-1h7a.997.997 0 0 1 1 1Z"
></path> ></path>
</svg> </svg>

View File

@@ -8,7 +8,7 @@ const Layout = () => {
return ( return (
<> <>
<Navbar /> <Navbar />
<NavbarDown/> {/* <NavbarDown/> */}
<main> <main>
<Outlet /> <Outlet />
</main> </main>

View File

@@ -10,7 +10,7 @@
align-items: center; align-items: center;
border-radius: 0.5rem; border-radius: 0.5rem;
height: 2.5rem; height: 2.5rem;
font-size: 0.875rem; font-size: 16px;
color: #4b5563; color: #4b5563;
background-color: transparent; background-color: transparent;
box-shadow: none; box-shadow: none;

View File

@@ -14,18 +14,35 @@
background-color: #fff; background-color: #fff;
margin-bottom: 1px; margin-bottom: 1px;
border-bottom: 3px solid #f3f4f6; border-bottom: 3px solid #f3f4f6;
position: sticky;
top: 0;
z-index: 100;
} }
.btn{ .btn {
display: flex; display: flex;
width: max-content; width: max-content;
font-size: 14px; font-size: 16px;
border-radius: 4px;
border: #000000;
background-color: #000000;
padding: 6px 10px;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
background-color: #000000;
border: 1px solid #000000; // Border rengini belirtirken kalınlık da eklemelisin
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
// Mobil Görünüm (Ortak)
@media screen and (max-width: 500px) {
font-size: 14px;
margin: 8px 10px 6px;
}
&__satyjy {
@media screen and (max-width: 785px) {
display: none;
}
}
} }
.navbarDown { .navbarDown {
@@ -33,7 +50,7 @@
background-color: #ffffff; background-color: #ffffff;
max-width: 1366px; max-width: 1366px;
position: sticky; position: sticky;
top: 0; top: 80px; // navbarUp yüksekliği kadar
padding-top: 12px; padding-top: 12px;
padding-bottom: 12px; padding-bottom: 12px;
padding-left: 1.375rem; padding-left: 1.375rem;
@@ -48,11 +65,11 @@
display: flex; display: flex;
width: 100%; width: 100%;
padding: 10px 22px 0px; padding: 10px 22px 0px;
height: 60px; height: 80px;
gap: 10px; gap: 10px;
margin: 0 auto; margin: 0 auto;
cursor: pointer; cursor: pointer;
@media screen and (max-width: 426px) { @media screen and (max-width: 500px) {
height: 40px; height: 40px;
justify-content: flex-start; justify-content: flex-start;
padding: 10px 15px 6px; padding: 10px 15px 6px;
@@ -66,14 +83,19 @@
box-sizing: border-box; box-sizing: border-box;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
@media screen and (max-width: 426px) { img {
width: 80px; width: 300px;
@media screen and (max-width: 500px) {
width: 100%;
}
}
@media screen and (max-width: 500px) {
width: 100%;
} }
svg { svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
.stick { .stick {
@@ -85,6 +107,9 @@
} }
} }
.navLinks {
width: 100%;
}
.navLinks ul { .navLinks ul {
list-style: none; list-style: none;
display: flex; display: flex;
@@ -106,8 +131,8 @@
margin: 0; margin: 0;
svg { svg {
fill: #4b5563; fill: #4b5563;
width: 20px; width: 24px;
height: 20px; height: 24px;
} }
} }
.searchWrapper { .searchWrapper {
@@ -115,7 +140,6 @@
align-items: center; align-items: center;
flex: 1; flex: 1;
flex-direction: row-reverse; flex-direction: row-reverse;
svg { svg {
position: absolute; position: absolute;
@@ -154,7 +178,7 @@
align-items: center; align-items: center;
border-radius: 0.5rem; border-radius: 0.5rem;
height: 2.5rem; height: 2.5rem;
font-size: 0.875rem; font-size: 16px;
color: #4b5563; color: #4b5563;
font-weight: 600; font-weight: 600;
background-color: transparent; background-color: transparent;
@@ -162,6 +186,10 @@
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
} }
svg {
width: 20px;
height: 20px;
}
} }
.cartSection { .cartSection {
@@ -192,7 +220,7 @@
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
display: none; display: none;
} }
@media screen and (max-width: 426px) { @media screen and (max-width: 500px) {
padding: 9px 0; padding: 9px 0;
} }
} }
@@ -250,9 +278,23 @@
border: none; border: none;
outline: none; outline: none;
&::placeholder { &::placeholder {
color: #9ca3af; color: #9ca3af;
font-size: 0.75rem; font-size: 0.75rem;
} }
} }
.langSelector {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
@media screen and (max-width: 708px) {
display: none;
}
}
.buttonsContainer {
display: flex;
gap: 8px;
margin: 8px 14px 6px;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { CartIcon, WishlistIcon, BrandIcon, OrderIcon } from "../Icons"; import { CartIcon, WishlistIcon, BrandIcon, OrderIcon, StoreIcon } from "../Icons";
import styles from "./Navbar.module.scss"; import styles from "./Navbar.module.scss";
import { UserOutlined, LogoutOutlined, HomeOutlined } from "@ant-design/icons"; import { UserOutlined, LogoutOutlined, HomeOutlined, ShopOutlined } from "@ant-design/icons";
import { FaGlobe } from "react-icons/fa6"; import { FaGlobe } from "react-icons/fa6";
import { Input, Badge, Menu, Dropdown } from "antd"; import { Input, Badge, Menu, Dropdown } from "antd";
const { Search } = Input; const { Search } = Input;
@@ -135,7 +135,7 @@ const NavbarDown = () => {
]; ];
return ( return (
<header className={styles.navbar}> <header className={styles.navbar} style={{ width: "100%" }}>
<div className={styles.navbarDown} style={{ position: "sticky" }}> <div className={styles.navbarDown} style={{ position: "sticky" }}>
<nav className={styles.navLinks}> <nav className={styles.navLinks}>
<ul> <ul>
@@ -151,6 +151,15 @@ const NavbarDown = () => {
</button> </button>
</Link> </Link>
</li> </li>
<div className={styles.stick}></div>
<li>
<Link to={"/stores"}>
<button className={styles.navButton}>
<ShopOutlined />
{t("navbar.stores")}
</button>
</Link>
</li>
<li className={styles.searchWrapper}> <li className={styles.searchWrapper}>
<CiSearch /> <CiSearch />
<input <input
@@ -214,7 +223,7 @@ const NavbarDown = () => {
count={ordersItemCount} count={ordersItemCount}
offset={[10, 0]} offset={[10, 0]}
> >
<button className={styles.navButton}> <button className={styles.navButton} >
<OrderIcon /> <OrderIcon />
</button> </button>
</Badge> </Badge>
@@ -255,7 +264,10 @@ const NavbarDown = () => {
</div> </div>
<div className={styles.stick}></div> <div className={styles.stick}></div>
<div className={styles.location}> <div className={styles.location}>
<CiLocationOn /> Aşgabat <Link to={'/stores'} style={{textDecoration: 'none'}}><button className={styles.navButton}>
<ShopOutlined />
{t("navbar.stores")}
</button></Link>
</div> </div>
<div className={styles.stick}></div> <div className={styles.stick}></div>
<div className={styles.searchIcon} onClick={toggleSearch}> <div className={styles.searchIcon} onClick={toggleSearch}>

View File

@@ -4,40 +4,101 @@ import SignupForm from "../BeSeller/index";
import React, { useState } from "react"; import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LogoWithText } from "../Icons"; import { LogoWithText } from "../Icons";
import Logo from "../../assets/logo2.png" import Logo from "../../assets/logo2.png";
import { useTranslation } from "react-i18next";
import tm from "../../assets/tm.png";
import ru from "../../assets/ru.png";
import en from "../../assets/en.png";
import NavbarDown from "./NavbarDown";
const Navbar = () => { const Navbar = () => {
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const showModal = () => { const { i18n } = useTranslation();
setIsModalVisible(true);
const changeLanguage = (langCode) => {
i18n.changeLanguage(langCode);
localStorage.setItem("preferredLanguage", langCode);
window.location.reload();
}; };
const handleCancel = () => { const languages = [
setIsModalVisible(false); { code: "tk", flag: tm, label: "TM" },
}; { code: "ru", flag: ru, label: "RU" },
{ code: "en", flag: en, label: "EN" },
];
const showModal = () => setIsModalVisible(true);
const handleCancel = () => setIsModalVisible(false);
return ( return (
<> <>
<header className={styles.navbar}> <header className={styles.navbar}>
<div className={styles.navbarUp}> <div className={styles.navbarUp}>
<div <div
style={{ maxWidth: "1366px", display: "flex", margin: "0 auto", alignItems: "center"}} style={{
maxWidth: "1366px",
display: "flex",
margin: "0 auto",
alignItems: "center",
}}
> >
<div className={styles.logo}> <div className={styles.logo}>
<div <div
className={styles.logoContainer} className={styles.logoContainer}
onClick={() => navigate("/")} onClick={() => navigate("/")}
> >
{/* <LogoWithText /> */} <img src={Logo} alt="" />
<img style={{width: "200px"}} src={Logo} alt="" />
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", padding: "8px 14px 6px" }}> <div className={styles.langSelector}>
<button className={styles.btn} onClick={showModal}> {languages.map((lang) => (
Satyjy bol <button
</button> key={lang.code}
onClick={() => changeLanguage(lang.code)}
style={{
display: "flex",
alignItems: "center",
fontSize: "16px",
gap: "4px",
background:
i18n.language === lang.code ? "#f0f0f0" : "transparent",
border:
i18n.language === lang.code
? "1px solid #d9d9d9"
: "1px solid transparent",
borderRadius: "4px",
padding: "6px 10px",
cursor: "pointer",
fontWeight: i18n.language === lang.code ? "600" : "400",
}}
>
<img
src={lang.flag}
alt={lang.label}
style={{ width: "20px" }}
/>
{lang.label}
</button>
))}
</div>
<div>
<div className={styles.buttonsContainer}>
<button className={styles.btn} onClick={showModal}>
Satyjy bol
</button>
<button
className={`${styles.btn} ${styles.btn__satyjy}`}
onClick={() => {
window.location.href = "/panel";
}}
>
Satyjy
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<NavbarDown />
</header> </header>
<Modal <Modal
open={isModalVisible} open={isModalVisible}

View File

@@ -0,0 +1,102 @@
.pendingPriceBadgeWrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.pendingPriceBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #faeeda;
border: 0.5px solid #ef9f27;
color: #854f0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.pendingPriceTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-background-primary, #ffffff);
border: 0.5px solid var(--color-border-secondary, #e2e2e2);
border-radius: var(--border-radius-md, 6px);
padding: 8px 12px;
width: 220px;
font-size: 13px;
color: var(--color-text-primary, #333333);
line-height: 1.5;
z-index: 100;
white-space: normal;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@media (max-width: 767px) {
display: none;
}
strong {
display: block;
margin-bottom: 4px;
color: var(--color-text-primary, #000000);
}
}
:global {
.pending-price-modal {
.ant-modal-content {
border-radius: 12px;
padding: 24px;
@media (max-width: 767px) {
padding: 20px;
}
}
.ant-modal-header {
margin-bottom: 12px;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
@media (max-width: 767px) {
font-size: 16px;
}
}
}
.ant-modal-body {
p {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0;
@media (max-width: 767px) {
font-size: 13px;
}
}
}
.ant-modal-footer {
margin-top: 20px;
.ant-btn-primary {
background-color: #888888;
border-color: #888888;
border-radius: 6px;
height: 36px;
padding: 0 20px;
font-weight: 500;
&:hover {
background-color: #666666;
border-color: #666666;
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
import React, { useState } from "react";
import { Modal } from "antd";
import { useTranslation } from "react-i18next";
import styles from "./PendingPriceBadge.module.scss";
const PendingPriceModal = ({ open, onClose, t }) => (
<Modal
open={open}
onOk={onClose}
onCancel={onClose}
okText={t("common.ok") || "Ok"}
cancelButtonProps={{ style: { display: "none" } }}
centered
title={t("cart.pendingPriceTitle") || "Bahasy anyklamaly"}
className="pending-price-modal"
width={400}
>
<p>
{t("cart.pendingPriceDesc") ||
"Bu sargytdaky bir ýa-da birnäçe harydyň bahasy entek kesgitlenmedik. Operatorymyz siziň bilen habarlaşyp, goşmaça maglumat berer."}
</p>
</Modal>
);
const PendingPriceBadge = () => {
const { t } = useTranslation();
const [tooltipVisible, setTooltipVisible] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
setModalVisible(true);
};
const stopPropagation = (e) => {
e.stopPropagation();
};
return (
<span onClick={stopPropagation}>
<span
className={styles.pendingPriceBadgeWrapper}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
onClick={handleClick}
onTouchEnd={(e) => {
e.stopPropagation();
}}
>
<span className={styles.pendingPriceBadge}>!</span>
{tooltipVisible && (
<span className={styles.pendingPriceTooltip}>
<strong>{t("cart.pendingPriceTitle") || "Bahasyny anyklamaly"}</strong>
{t("cart.pendingPriceTooltipDesc") ||
"Bu sargytdaky harydyň bahasy kesgitlenmedik. Operator size jaň edip goşmaça maglumat berer."}
</span>
)}
</span>
<PendingPriceModal
open={modalVisible}
onClose={() => setModalVisible(false)}
t={t}
/>
</span>
);
};
export default PendingPriceBadge;

View File

@@ -5,14 +5,14 @@
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease; transition: all 0.3s ease;
text-decoration: none; text-decoration: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
&:hover { &:hover {
transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
cursor: pointer; cursor: pointer;
} }
@media screen and (max-width: 426px) { @media screen and (max-width: 426px) {
@@ -30,6 +30,7 @@
border-radius: 8px; border-radius: 8px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
z-index: 1;
@media screen and (max-width: 426px) { @media screen and (max-width: 426px) {
font-size: 12px; font-size: 12px;
} }
@@ -71,8 +72,16 @@
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 2.4em;
line-height: 1.2;
@media screen and (max-width: 426px) { @media screen and (max-width: 426px) {
font-size: 14px; font-size: 14px;
height: 2.8em;
} }
} }
@@ -81,9 +90,15 @@
color: #666; color: #666;
line-height: 1.4; line-height: 1.4;
margin: 0; margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 2.8em;
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
font-size: 12px; font-size: 12px;
height: 2.8em;
} }
} }
@@ -91,8 +106,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; margin-top: auto;
margin: 0; margin-bottom: 0;
justify-content: space-between; justify-content: space-between;
} }

View File

@@ -4,8 +4,8 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
touch-action: pan-y; touch-action: pan-y;
border-radius: 8px;
} }
.productImage { .productImage {
width: 99%; width: 99%;
height: auto; height: auto;
@@ -31,6 +31,11 @@
max-width: 100%; max-width: 100%;
margin: auto; margin: auto;
object-fit: contain; object-fit: contain;
transition: transform 0.3s ease;
}
.hovered {
transform: scale(1.05);
} }
/* Style for images inside detail view */ /* Style for images inside detail view */
@@ -145,6 +150,7 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
position: relative; position: relative;
padding: 12px;
} }
.thumbnail { .thumbnail {

View File

@@ -6,6 +6,7 @@ const ImageCarousel = ({
altText, altText,
showThumbnails = false, showThumbnails = false,
isDetailView = false, isDetailView = false,
isHovered = false,
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -28,15 +29,15 @@ const ImageCarousel = ({
: images[0]?.images_1200x1200 || ""; : images[0]?.images_1200x1200 || "";
// Auto-slide functionality // Auto-slide functionality
useEffect(() => { // useEffect(() => {
if (!hasMultipleImages || isModalOpen) return; // if (!hasMultipleImages || isModalOpen) return;
const interval = setInterval(() => { // const interval = setInterval(() => {
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); // setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
}, 9000); // }, 9000);
return () => clearInterval(interval); // return () => clearInterval(interval);
}, [hasMultipleImages, images, isModalOpen]); // }, [hasMultipleImages, images, isModalOpen]);
// Reset zoom/rotation when modal closes or image changes // Reset zoom/rotation when modal closes or image changes
useEffect(() => { useEffect(() => {
@@ -220,7 +221,8 @@ const ImageCarousel = ({
isDetailView ? styles.detailImage : styles.cardImage isDetailView ? styles.detailImage : styles.cardImage
}`} }`}
onClick={isDetailView ? openModal : undefined} onClick={isDetailView ? openModal : undefined}
style={{ cursor: isDetailView ? "pointer" : "default" }} style={{ cursor: isDetailView ? "pointer" : "default" , transform: isHovered ? "scale(1.05)" : "none" }}
/> />
{isDetailView && renderModal()} {isDetailView && renderModal()}
</div> </div>
@@ -450,7 +452,7 @@ const ImageCarousel = ({
alt={altText || "Product image"} alt={altText || "Product image"}
className={`${styles.productImage} ${ className={`${styles.productImage} ${
isDetailView ? styles.detailImage : styles.cardImage isDetailView ? styles.detailImage : styles.cardImage
}`} } ${isHovered ? styles.hovered : ''}`}
/> />
</div> </div>

View File

@@ -3,36 +3,32 @@ import styles from "./ProductCard.module.scss";
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io"; import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
import { FaShoppingCart } from "react-icons/fa"; import { FaShoppingCart } from "react-icons/fa";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { debounce } from "lodash";
import { import {
useAddFavoriteMutation, useAddFavoriteMutation,
useRemoveFavoriteMutation, useRemoveFavoriteMutation,
useGetFavoritesQuery,
} from "../../app/api/favoritesApi"; } from "../../app/api/favoritesApi";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { import {
useAddToCartMutation, useAddToCartMutation,
useUpdateCartItemMutation, useUpdateCartItemMutation,
useRemoveFromCartMutation, useRemoveFromCartMutation,
useGetCartQuery,
} from "../../app/api/cartApi"; } from "../../app/api/cartApi";
import { Modal } from "antd"; import { Modal } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DecreaseIcon, IncreaseIcon } from "../Icons"; import { DecreaseIcon, IncreaseIcon } from "../Icons";
import ImageCarousel from "./imageCarousel/index"; import ImageCarousel from "./imageCarousel/index";
import { useCart } from "../../app/api/useCart";
// Helper function to strip HTML tags and truncate text
const truncateDescription = (htmlString, maxLength = 80) => { const truncateDescription = (htmlString, maxLength = 80) => {
const tempDiv = document.createElement("div"); const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlString; tempDiv.innerHTML = htmlString;
const textContent = tempDiv.textContent || tempDiv.innerText || ""; const textContent = tempDiv.textContent || tempDiv.innerText || "";
const truncatedText = return textContent.length > maxLength
textContent.length > maxLength ? textContent.substring(0, maxLength).trim() + "..."
? textContent.substring(0, maxLength).trim() + "..." : textContent;
: textContent;
return truncatedText;
}; };
import { useCart } from "../../app/api/useCart"; const isPriceZero = (price) => !price || parseFloat(price) === 0;
const ProductCard = ({ const ProductCard = ({
product, product,
@@ -41,26 +37,24 @@ const ProductCard = ({
onAddToCart, onAddToCart,
onToggleFavorite, onToggleFavorite,
isFavorite = false, isFavorite = false,
descriptionMaxLength = 85, descriptionMaxLength = 120,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false); const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [addFavorite] = useAddFavoriteMutation(); const [addFavorite] = useAddFavoriteMutation();
const [removeFavorite] = useRemoveFavoriteMutation(); const [removeFavorite] = useRemoveFavoriteMutation();
const { data: favoriteProducts = [] } = useGetFavoritesQuery(); const { data: favoriteProducts = [] } = useGetFavoritesQuery();
const [isLoading, setIsLoading] = useState(false);
const [localIsFavorite, setLocalIsFavorite] = useState(
favoriteProducts.some((fav) => fav.product?.id === product.id),
);
const truncatedDesc = truncateDescription( const [localIsFavorite, setLocalIsFavorite] = useState(
product.description, favoriteProducts.some((fav) => fav.product?.id === product.id)
descriptionMaxLength,
); );
const { getCartItem } = useCart(); const { getCartItem } = useCart();
const [addToCart] = useAddToCartMutation(); const [addToCart] = useAddToCartMutation();
const [updateCartItem] = useUpdateCartItemMutation(); const [updateCartItem] = useUpdateCartItemMutation();
const [removeFromCart] = useRemoveFromCartMutation(); const [removeFromCart] = useRemoveFromCartMutation();
@@ -69,26 +63,54 @@ const ProductCard = ({
const [localQuantity, setLocalQuantity] = useState(0); const [localQuantity, setLocalQuantity] = useState(0);
const [pendingQuantity, setPendingQuantity] = useState(0); const [pendingQuantity, setPendingQuantity] = useState(0);
// ✅ Cart item değiştiğinde local state'i güncelle const { name, price_amount, old_price_amount, media = [], reviews } = product;
const truncatedDesc = truncateDescription(product.description, descriptionMaxLength);
const calculatedDiscount =
!product.discount &&
old_price_amount &&
price_amount &&
old_price_amount > price_amount
? Math.round(((old_price_amount - price_amount) / old_price_amount) * 100)
: null;
useEffect(() => { useEffect(() => {
const qty = parseInt( const qty = parseInt(cartItem?.quantity || cartItem?.product_quantity || 0, 10);
cartItem?.quantity || cartItem?.product_quantity || 0,
10,
);
setLocalQuantity(qty); setLocalQuantity(qty);
setPendingQuantity(qty); setPendingQuantity(qty);
}, [cartItem]); }, [cartItem]);
// ✅ Favorite state'i güncelle
useEffect(() => { useEffect(() => {
if (Array.isArray(favoriteProducts)) { if (Array.isArray(favoriteProducts)) {
const isFav = favoriteProducts.some( setLocalIsFavorite(
(fav) => fav.product?.id === product.id, favoriteProducts.some((fav) => fav.product?.id === product.id)
); );
setLocalIsFavorite(isFav);
} }
}, [favoriteProducts, product.id]); }, [favoriteProducts, product.id]);
useEffect(() => {
const serverQty = parseInt(cartItem?.quantity || cartItem?.product_quantity || 0, 10);
if (pendingQuantity === serverQty || pendingQuantity <= 0) return;
const handler = setTimeout(async () => {
try {
setIsLoading(true);
await updateCartItem({ productId: product.id, quantity: pendingQuantity }).unwrap();
} catch {
setLocalQuantity(serverQty);
setPendingQuantity(serverQty);
} finally {
setIsLoading(false);
}
}, 500);
return () => clearTimeout(handler);
}, [pendingQuantity, cartItem, product.id, updateCartItem]);
const handleCardClick = () => navigate(`/product/${product.id}`);
const handleAddToCart = async (event) => { const handleAddToCart = async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -98,51 +120,17 @@ const ProductCard = ({
return; return;
} }
// ✅ Optimistic update
setLocalQuantity((prev) => prev + 1); setLocalQuantity((prev) => prev + 1);
setPendingQuantity((prev) => prev + 1); setPendingQuantity((prev) => prev + 1);
try { try {
await addToCart({ productId: product.id, quantity: 1 }).unwrap(); await addToCart({ productId: product.id, quantity: 1 }).unwrap();
// ✅ Başarılı - RTK Query otomatik cache'i güncelleyecek } catch {
} catch (error) {
console.error("Failed to add to cart:", error);
// ✅ Hata varsa geri al
setLocalQuantity((prev) => prev - 1); setLocalQuantity((prev) => prev - 1);
setPendingQuantity((prev) => prev - 1); setPendingQuantity((prev) => prev - 1);
} }
}; };
// ✅ Debounced update - sadece mutation, refetch yok
useEffect(() => {
const serverQty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0,
10,
);
if (pendingQuantity === serverQty || pendingQuantity <= 0) {
return;
}
const handler = setTimeout(async () => {
try {
setIsLoading(true);
await updateCartItem({
productId: product.id,
quantity: pendingQuantity,
}).unwrap();
} catch (error) {
console.error("Failed to update cart item:", error);
setLocalQuantity(serverQty);
setPendingQuantity(serverQty);
} finally {
setIsLoading(false);
}
}, 500);
return () => clearTimeout(handler);
}, [pendingQuantity, cartItem, product.id, updateCartItem]);
const handleQuantityIncrease = (event) => { const handleQuantityIncrease = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -165,24 +153,17 @@ const ProductCard = ({
if (isLoading) return; if (isLoading) return;
if (pendingQuantity <= 1) { if (pendingQuantity <= 1) {
// ✅ Sıfıra düşünce direkt sil
setPendingQuantity(0); setPendingQuantity(0);
setLocalQuantity(0); setLocalQuantity(0);
setIsLoading(true); setIsLoading(true);
removeFromCart({ productId: product.id }) removeFromCart({ productId: product.id })
.unwrap() .unwrap()
.then(() => {
// ✅ Başarılı - RTK Query cache'i güncelleyecek
})
.catch(() => { .catch(() => {
// ✅ Hata varsa geri al
setLocalQuantity(1); setLocalQuantity(1);
setPendingQuantity(1); setPendingQuantity(1);
}) })
.finally(() => { .finally(() => setIsLoading(false));
setIsLoading(false);
});
} else { } else {
setLocalQuantity((prev) => prev - 1); setLocalQuantity((prev) => prev - 1);
setPendingQuantity((prev) => prev - 1); setPendingQuantity((prev) => prev - 1);
@@ -196,61 +177,63 @@ const ProductCard = ({
if (isLoading) return; if (isLoading) return;
setIsLoading(true); setIsLoading(true);
setLocalIsFavorite((prev) => !prev);
// ✅ Optimistic update
setLocalIsFavorite(!localIsFavorite);
try { try {
if (localIsFavorite) { if (localIsFavorite) {
const result = await removeFavorite(product.id).unwrap(); await removeFavorite(product.id).unwrap();
// ✅ Başarılı - RTK Query otomatik güncelleyecek
} else { } else {
const result = await addFavorite(product.id).unwrap(); await addFavorite(product.id).unwrap();
// ✅ Başarılı - RTK Query otomatik güncelleyecek
} }
} catch (error) { } catch {
console.error("Failed to toggle favorite:", error); setLocalIsFavorite((prev) => !prev); // revert
// ✅ Hata varsa geri al
setLocalIsFavorite(localIsFavorite);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleCardClick = () => {
navigate(`/product/${product.id}`);
};
const { name, price_amount, old_price_amount, media = [], reviews } = product;
return ( return (
<> <>
<div className={styles.productCard} onClick={handleCardClick}> <div
className={styles.productCard}
onClick={handleCardClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
{product.discount && ( {(product.discount > 0 || calculatedDiscount > 0) && (
<span className={styles.discountBadge}>-{product.discount}%</span> <span className={styles.discountBadge}>
-{product.discount || calculatedDiscount}%
</span>
)} )}
{product.stock === 0 && ( {product.stock === 0 && (
<span className={`${styles.discountBadge} ${styles.outOfStock}`}> <span className={`${styles.discountBadge} ${styles.outOfStock}`}>
{t("common.out_of_stock")} {t("common.out_of_stock")}
</span> </span>
)} )}
<ImageCarousel images={media} altText={name} isHovered={isHovered} />
<ImageCarousel images={media} altText={name} />
</div> </div>
<div className={styles.productInfo}> <div className={styles.productInfo}>
<h3 className={styles.productName}>{name}</h3> <h3 className={styles.productName}>{name}</h3>
<p className={styles.productDescription}>{truncatedDesc}</p> <p className={styles.productDescription}>{truncatedDesc}</p>
<div className={styles.priceContainer}> <div className={styles.priceContainer}>
<div> <div>
<span className={styles.currentPrice}>{price_amount} m.</span> {isPriceZero(price_amount) ? (
{old_price_amount && ( <span className={styles.currentPrice}> {t("cart.pendingPriceTitle")}</span>
<span className={styles.oldPrice}>{old_price_amount} m.</span> ) : (
<>
<span className={styles.currentPrice}>{price_amount} m.</span>
{old_price_amount && (
<span className={styles.oldPrice}>{old_price_amount} m.</span>
)}
</>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
{showFavoriteButton && ( {showFavoriteButton && (
<button <button
@@ -261,6 +244,7 @@ const ProductCard = ({
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />} {localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button> </button>
)} )}
{showAddToCart && ( {showAddToCart && (
<> <>
{localQuantity > 0 ? ( {localQuantity > 0 ? (
@@ -322,4 +306,4 @@ const ProductCard = ({
); );
}; };
export default ProductCard; export default ProductCard;

View File

@@ -12,24 +12,23 @@ import {
Info, Info,
Edit, Edit,
MapPin, MapPin,
Store,
LogOut, LogOut,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styles from "./ProfileMenu.module.scss"; import styles from "./ProfileMenu.module.scss";
import LoginModal from "../LogIn"; import LoginModal from "../LogIn";
import SignUpModal from "../SignUp"; import SignUpModal from "../SignUp";
import ProfileModal from "..//MyProfileModal/index"; import ProfileModal from "..//MyProfileModal/index";
import tm from "../../assets/tm.png"; import tm from "../../assets/tm.png";
import ru from "../../assets/ru.png"; import ru from "../../assets/ru.png";
import en from "../../assets/en.png"; import en from "../../assets/en.png";
import { useAuth } from "../../context/authContext"; import { useAuth } from "../../context/authContext";
import { useGetProfileQuery } from "../../app/api/myProfileApi"; import { useGetProfileQuery } from "../../app/api/myProfileApi";
const ProfileMenu = () => { const ProfileMenu = () => {
const [activeModal, setActiveModal] = useState(null); const [activeModal, setActiveModal] = useState(null);
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
// Fetch profile data from API // Fetch profile data from API
const { data: profileData, isLoading } = useGetProfileQuery(undefined, { const { data: profileData, isLoading } = useGetProfileQuery(undefined, {
skip: !isAuthenticated, // Skip the API call if not authenticated skip: !isAuthenticated, // Skip the API call if not authenticated
@@ -55,6 +54,11 @@ const ProfileMenu = () => {
return; return;
} }
if (item.action === "/panel") {
window.location.href = "/panel";
return;
}
if (item.action) { if (item.action) {
setActiveModal(item.action); setActiveModal(item.action);
} }
@@ -62,7 +66,7 @@ const ProfileMenu = () => {
const handleLanguageChange = async (langCode) => { const handleLanguageChange = async (langCode) => {
await i18n.changeLanguage(langCode); await i18n.changeLanguage(langCode);
localStorage.setItem("preferredLanguage", langCode); localStorage.setItem("preferredLanguage", langCode);
setActiveModal(null); setActiveModal(null);
window.location.reload(); window.location.reload();
}; };
@@ -84,6 +88,7 @@ const ProfileMenu = () => {
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" }, { icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" }, { icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
{ icon: <Languages />, text: t("profile.language"), action: "language" }, { icon: <Languages />, text: t("profile.language"), action: "language" },
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
{ {
icon: <List />, icon: <List />,
text: t("profile.delivery"), text: t("profile.delivery"),
@@ -102,7 +107,9 @@ const ProfileMenu = () => {
<User className={styles.userIcon} /> <User className={styles.userIcon} />
</div> </div>
<div className={styles.userInfo}> <div className={styles.userInfo}>
<div className={styles.phoneNumber}>+993 {userData.phone_number}</div> <div className={styles.phoneNumber}>
+993 {userData.phone_number}
</div>
<button <button
onClick={handleEditProfile} onClick={handleEditProfile}
className={styles.editProfileLink} className={styles.editProfileLink}
@@ -192,6 +199,7 @@ const ProfileMenu = () => {
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" }, { icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" }, { icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
{ icon: <Languages />, text: t("profile.language"), action: "language" }, { icon: <Languages />, text: t("profile.language"), action: "language" },
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
{ {
icon: <List />, icon: <List />,
text: t("profile.delivery"), text: t("profile.delivery"),

View File

@@ -0,0 +1,241 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import styles from './StoryViewer.module.scss';
const STORY_DURATION = 6000;
const StoryViewer = ({ stories, onClose, initialIndex = 0, onStoryViewed }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [animClass, setAnimClass] = useState(styles.slideInFromRight);
const [progress, setProgress] = useState(0); // 0..1
const touchStartX = useRef(null);
const touchStartY = useRef(null);
const isPausedRef = useRef(false);
const rafRef = useRef(null);
const startTimeRef = useRef(null);
const elapsedRef = useRef(0); // ms elapsed before last pause
const isFirst = currentIndex === 0;
const isLast = currentIndex === stories.length - 1;
const currentStory = stories[currentIndex];
// Scroll lock
useEffect(() => {
const orig = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = orig; };
}, []);
// ── Core timer: runs per-frame, advances progress, fires goNext ──
const startTimer = useCallback((onDone) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
startTimeRef.current = Date.now();
elapsedRef.current = 0;
setProgress(0);
const tick = () => {
if (isPausedRef.current) {
// While paused keep scheduling but don't advance
rafRef.current = requestAnimationFrame(tick);
return;
}
const now = Date.now();
const delta = now - startTimeRef.current;
startTimeRef.current = now;
elapsedRef.current += delta;
const p = Math.min(elapsedRef.current / STORY_DURATION, 1);
setProgress(p);
if (p >= 1) {
onDone();
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, []);
// ── Navigate ──
const goTo = useCallback((nextIndex, dir) => {
if (nextIndex < 0 || nextIndex >= stories.length) return;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
setAnimClass(dir === 'next' ? styles.slideInFromRight : styles.slideInFromLeft);
setCurrentIndex(nextIndex);
}, [stories.length]);
const goToNext = useCallback(() => {
if (!isLast) goTo(currentIndex + 1, 'next');
else onClose();
}, [currentIndex, isLast, goTo, onClose]);
const goToPrevious = useCallback(() => {
if (!isFirst) goTo(currentIndex - 1, 'prev');
}, [currentIndex, isFirst, goTo]);
// ── Start timer when index changes ──
useEffect(() => {
if (onStoryViewed) onStoryViewed(currentIndex);
const cancel = startTimer(() => {
if (currentIndex < stories.length - 1) {
setAnimClass(styles.slideInFromRight);
setCurrentIndex((i) => i + 1);
} else {
onClose();
}
});
return cancel;
}, [currentIndex, stories.length]); // NO isPaused here — handled by ref
// ── Keyboard ──
useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [goToPrevious, goToNext, onClose]);
// ── Touch swipe ──
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
const dy = e.changedTouches[0].clientY - touchStartY.current;
touchStartX.current = null;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
if (dx < 0) goToNext();
else goToPrevious();
};
const handleMouseEnter = () => {
isPausedRef.current = true;
// Snapshot elapsed so we resume correctly
if (startTimeRef.current !== null) {
elapsedRef.current += Date.now() - startTimeRef.current;
startTimeRef.current = null;
}
};
const handleMouseLeave = () => {
isPausedRef.current = false;
startTimeRef.current = Date.now(); // reset delta start
};
if (!currentStory) return null;
const getTimeAgo = (dateString) => {
if (!dateString) return '';
const diff = Math.floor((Date.now() - new Date(dateString)) / 1000);
if (diff < 60) return 'şimdi';
if (diff < 3600) return `${Math.floor(diff / 60)}d`;
if (diff < 86400) return `${Math.floor(diff / 3600)}s`;
return `${Math.floor(diff / 86400)}g`;
};
return (
<div className={styles.overlay} onClick={onClose}>
{!isFirst && (
<button
className={`${styles.outerNav} ${styles.outerNavLeft}`}
onClick={(e) => { e.stopPropagation(); goToPrevious(); }}
aria-label="Önceki"
>
<ChevronLeft size={28} />
</button>
)}
<div
className={styles.container}
onClick={(e) => e.stopPropagation()}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Progress bars — driven by JS progress state */}
<div className={styles.progressBars}>
{stories.map((_, idx) => (
<div key={idx} className={styles.track}>
{idx < currentIndex && (
<div className={styles.bar} style={{ width: '100%' }} />
)}
{idx === currentIndex && (
<div className={styles.bar} style={{ width: `${progress * 100}%` }} />
)}
</div>
))}
</div>
{/* Header */}
<div className={styles.header}>
<div className={styles.identity}>
<div className={styles.avatar}>
<img src={currentStory.thumbnail || currentStory.photo} alt={currentStory.title} />
</div>
<div>
<p className={styles.name}>{currentStory.title}</p>
{currentStory.createdAt && (
<p className={styles.time}>{getTimeAgo(currentStory.createdAt)}</p>
)}
</div>
</div>
<button className={styles.close} onClick={onClose} aria-label="Kapat">
<X size={20} />
</button>
</div>
{/* Image */}
<div className={styles.media}>
<img
key={currentIndex}
src={currentStory.photo || currentStory.image}
alt={currentStory.title}
className={animClass}
/>
<button
className={`${styles.tap} ${styles.tapLeft}`}
onClick={goToPrevious}
disabled={isFirst}
aria-label="Önceki"
/>
<button
className={`${styles.tap} ${styles.tapRight}`}
onClick={goToNext}
aria-label="Sonraki"
/>
</div>
{(currentStory.description || currentStory.caption) && (
<div className={styles.footer}>
<p className={styles.caption}>
{currentStory.description || currentStory.caption}
</p>
</div>
)}
</div>
{!isLast && (
<button
className={`${styles.outerNav} ${styles.outerNavRight}`}
onClick={(e) => { e.stopPropagation(); goToNext(); }}
aria-label="Sonraki"
>
<ChevronRight size={28} />
</button>
)}
</div>
);
};
export default StoryViewer;

View File

@@ -0,0 +1,245 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
overflow: hidden;
}
.container {
position: relative;
width: 100%;
max-width: 420px;
height: 90vh;
max-height: 780px;
display: flex;
flex-direction: column;
background: #111;
border-radius: 16px;
overflow: hidden;
touch-action: pan-y;
@media (max-width: 480px) {
max-width: 100vw;
height: 100dvh;
max-height: none;
border-radius: 0;
}
}
/* ── Outer nav ── */
.outerNav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: background 0.15s, transform 0.15s;
backdrop-filter: blur(4px);
&:hover {
background: rgba(255, 255, 255, 0.22);
transform: translateY(-50%) scale(1.08);
}
&:active { transform: translateY(-50%) scale(0.95); }
@media (max-width: 600px) { display: none; }
}
.outerNavLeft {
left: calc(50% - 210px - 60px);
@media (max-width: 700px) { left: 8px; }
}
.outerNavRight {
right: calc(50% - 210px - 60px);
@media (max-width: 700px) { right: 8px; }
}
/* ── Progress bars — JS driven, no CSS animation ── */
.progressBars {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
gap: 3px;
padding: 10px 10px 0;
z-index: 20;
}
.track {
flex: 1;
height: 2px;
background: rgba(255, 255, 255, 0.25);
border-radius: 2px;
overflow: hidden;
position: relative;
}
/* Single bar element — width set via inline style */
.bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #e53935;
border-radius: 2px;
/* No transition — rAF updates are fast enough */
}
/* ── Header ── */
.header {
position: absolute;
top: 22px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
z-index: 20;
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, transparent 100%);
}
.identity {
display: flex;
align-items: center;
gap: 9px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.65);
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.name {
color: #fff;
font-size: 13px;
font-weight: 600;
margin: 0;
line-height: 1.2;
}
.time {
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
margin: 0;
margin-top: 1px;
}
.close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
flex-shrink: 0;
&:hover { color: #fff; }
}
/* ── Media ── */
.media {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
.slideInFromRight {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
animation: slideInRight 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.slideInFromLeft {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
animation: slideInLeft 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
/* Tap zones */
.tap {
position: absolute;
top: 0;
bottom: 0;
width: 40%;
background: transparent;
border: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
z-index: 10;
&:disabled { pointer-events: none; }
}
.tapLeft { left: 0; }
.tapRight { right: 0; }
/* ── Footer ── */
.footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 18px;
background: linear-gradient(0deg, rgba(0,0,0,0.72) 0%, transparent 100%);
z-index: 20;
}
.caption {
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
/* ── Keyframes ── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(5%) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-5%) scale(0.98); }
to { opacity: 1; transform: translateX(0) scale(1); }
}

View File

@@ -1,9 +1,11 @@
export default { export default {
navbar: { navbar: {
category: "Categories", category: "Categories",
login: "Login", login: "Login",
signUp: "Sign Up", signUp: "Sign Up",
brands: "Brands", brands: "Brands",
stores: "Stores",
search: "Search by product name...", search: "Search by product name...",
cart: "Cart", cart: "Cart",
home: "Home", home: "Home",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "FLASH SALE",
ends_in: "Ends in:",
day: "day",
hour: "hr",
minute: "min",
second: "sec",
}, },
cart: { cart: {
basket: "Basket", basket: "Basket",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Your cart is empty", emptyCartTitle: "Your cart is empty",
emptyCartMessage: "Looks like you haven't added any items to your cart yet", emptyCartMessage: "Looks like you haven't added any items to your cart yet",
continueShopping: "Continue Shopping", continueShopping: "Continue Shopping",
pendingPriceTitle: "Price pending",
pendingPriceDesc: "The price of one or more items in this order has not yet been determined. Our operator will contact you to provide additional information.",
pendingPriceTooltipDesc: "The price of this item in the order has not been determined. The operator will call you and provide additional information."
}, },
checkout: { checkout: {
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
@@ -129,6 +142,8 @@ export default {
verify: "Verify", verify: "Verify",
name: "Name", name: "Name",
address: "Address", address: "Address",
seller_panel: "Seller Panel",
}, },
order: { order: {
orderDate: "Order Date", orderDate: "Order Date",
@@ -172,11 +187,25 @@ export default {
price: "Price", price: "Price",
minPrice: "Min Price", minPrice: "Min Price",
maxPrice: "Max Price", maxPrice: "Max Price",
}, priceHighToLow: "From expensive to cheap",
priceLowToHigh: "From cheap to expensive",
priceRange: "Price Range",
under50: "Under 50m",
under100: "Under 100m",
from50to200: "50 - 200",
from200to500: "200 - 500",
from500to1000: "500 - 1000",
over1000: "Over 1000m",
sortBy: "Sort By",
},
product: { product: {
productCode: "Product code", productCode: "Product code",
barCode: "Barcode", barCode: "Barcode",
similarProducts: "Similar Products", similarProducts: "Similar Products",
description: "Product description",
price: "Price",
readMore: "Read more...",
readLess: "Show less",
}, },
wishtList: { wishtList: {
likedProducts: "Favorites", likedProducts: "Favorites",

View File

@@ -1,9 +1,11 @@
export default { export default {
navbar: { navbar: {
category: "Категории", category: "Категории",
login: "Войти", login: "Войти",
signUp: "Регистрация", signUp: "Регистрация",
brands: "Бренды", brands: "Бренды",
stores: "Магазины",
search: "Поиск по названию товара...", search: "Поиск по названию товара...",
cart: "Корзина", cart: "Корзина",
home: "Главная", home: "Главная",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "ФЛЭШ-РАСПРОДАЖА",
ends_in: "До конца:",
day: "дн.",
hour: "ч.",
minute: "мин.",
second: "сек.",
}, },
cart: { cart: {
basket: "Корзина", basket: "Корзина",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Ваша корзина пуста", emptyCartTitle: "Ваша корзина пуста",
emptyCartMessage: "Похоже, вы еще не добавили ни одного товара в корзину", emptyCartMessage: "Похоже, вы еще не добавили ни одного товара в корзину",
continueShopping: "Продолжить покупки", continueShopping: "Продолжить покупки",
pendingPriceTitle: "Цена уточняется",
pendingPriceDesc: "Цена на один или несколько товаров в этом заказе еще не определена. Наш оператор свяжется с вами для предоставления дополнительной информации.",
pendingPriceTooltipDesc: "Цена на этот товар в заказе не определена. Оператор позвонит вам и предоставит дополнительную информацию."
}, },
checkout: { checkout: {
paymentMethod: "Способ оплаты", paymentMethod: "Способ оплаты",
@@ -126,6 +139,7 @@ export default {
name: "Имя", name: "Имя",
address: "Address", address: "Address",
lastname: "Фамилия", lastname: "Фамилия",
seller_panel: "Панель продавца",
}, },
order: { order: {
orderDate: "Дата заказа", orderDate: "Дата заказа",
@@ -167,13 +181,27 @@ export default {
From_expensive_to_cheap: "От дорогих к дешевым", From_expensive_to_cheap: "От дорогих к дешевым",
From_cheap_to_expensive: "От дешевых к дорогим", From_cheap_to_expensive: "От дешевых к дорогим",
price: "Цена", price: "Цена",
minPrice: "Минимальная цена", minPrice: "Мин цена",
maxPrice: "Максимальная цена", maxPrice: "Макс цена",
}, priceHighToLow: "От дорогих к дешевым",
priceLowToHigh: "От дешевых к дорогим",
priceRange: "Диапазон цен",
under50: "До 50m",
under100: "До 100m",
from50to200: "50 - 200",
from200to500: "200 - 500",
from500to1000: "500 - 1000",
over1000: "Более 1000m",
sortBy: "Сортировать по",
},
product: { product: {
productCode: "Код товара", productCode: "Код товара",
barCode: "Штрих-код", barCode: "Штрих-код",
similarProducts: "Похожие товары", similarProducts: "Похожие товары",
description: "Описание товара",
price: "Цена",
readMore: "Читать далее...",
readLess: "Свернуть",
}, },
wishtList: { wishtList: {
likedProducts: "Избранные", likedProducts: "Избранные",

View File

@@ -1,9 +1,11 @@
export default { export default {
navbar: { navbar: {
category: "Kategoriýalar", category: "Kategoriýalar",
login: "Giriş", login: "Giriş",
signUp: "Agza bolmak", signUp: "Agza bolmak",
brands: "Brendler", brands: "Brendler",
stores: "Dükanlar",
search: "Haryt ady boýunça gözleg...", search: "Haryt ady boýunça gözleg...",
cart: "Sebet", cart: "Sebet",
home: "Baş sahypa", home: "Baş sahypa",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "GYSGA WAGTLYK ARZANLADYŞ",
ends_in: "Gutarýança:",
day: "gün",
hour: "sag",
minute: "min",
second: "sek",
}, },
cart: { cart: {
basket: "Sebet", basket: "Sebet",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Sebediňiz boş", emptyCartTitle: "Sebediňiz boş",
emptyCartMessage: "Sebediňize entek hiç zat goşmadyňyz.", emptyCartMessage: "Sebediňize entek hiç zat goşmadyňyz.",
continueShopping: "Söwda etmegi dowam etdiriň", continueShopping: "Söwda etmegi dowam etdiriň",
pendingPriceTitle: "Bahasyny anyklamaly",
pendingPriceDesc: "Bu sargytdaky bir ýa-da birnäçe harydyň bahasy entek kesgitlenmedik. Operatorymyz siziň bilen habarlaşyp, goşmaça maglumat berer.",
pendingPriceTooltipDesc: "Bu sargytdaky harydyň bahasy kesgitlenmedik. Operator size jaň edip goşmaça maglumat berer."
}, },
checkout: { checkout: {
paymentMethod: "Töleg görnüşi", paymentMethod: "Töleg görnüşi",
@@ -129,6 +142,7 @@ export default {
name: "Ady", name: "Ady",
address: "Salgy", address: "Salgy",
lastname: "Familýaňyz", lastname: "Familýaňyz",
seller_panel: "Satyjy paneli",
}, },
order: { order: {
orderDate: "Sargyt senesi", orderDate: "Sargyt senesi",
@@ -170,13 +184,27 @@ export default {
From_expensive_to_cheap: "Gymmatdan arzana", From_expensive_to_cheap: "Gymmatdan arzana",
From_cheap_to_expensive: "Arzandan gymmada", From_cheap_to_expensive: "Arzandan gymmada",
price: "Bahasy", price: "Bahasy",
maxPrice: "Maksimum baha", maxPrice: "Maks baha",
minPrice: "Minimum baha", minPrice: "Min baha",
}, priceHighToLow: "Gymmatdan arzana",
priceLowToHigh: "Arzandan gymmada",
priceRange: "Baha diapazony",
under50: "50m aşagynda",
under100: "100m aşagynda",
from50to200: "50 - 200",
from200to500: "200 - 500",
from500to1000: "500 - 1000",
over1000: "1000m dan ýokary",
sortBy: "Tertiplemek",
},
product: { product: {
productCode: "Haryt kody", productCode: "Haryt kody",
barCode: "Çyzgyç kod", barCode: "Çyzgyç kod",
similarProducts: "Meňzeş harytlar", similarProducts: "Meňzeş harytlar",
description: "Haryt barada düşündiriş",
price: "Bahasy",
readMore: "Giňişleýin oka...",
readLess: "Gysgaltmak",
}, },
wishtList: { wishtList: {
likedProducts: "Halanlarym", likedProducts: "Halanlarym",

View File

@@ -0,0 +1,651 @@
// ── Variables ─────────────────────────────────────────────────
$orange: #f26522;
$orange-dark: #d4551a;
$bg: #f0f0f0;
$white: #ffffff;
$border: #e0e0e0;
$text: #1a1a1a;
$muted: #888;
$danger: #e53935;
$success: #2e7d32;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 16px;
$transition: 0.2s ease;
// ── Login ─────────────────────────────────────────────────────
.loginWrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
padding: 20px;
}
.loginCard {
background: $white;
border-radius: $radius-lg;
padding: 48px 40px;
width: 100%;
max-width: 380px;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.4);
text-align: center;
}
.loginLogo { font-size: 3rem; margin-bottom: 16px; }
.loginTitle {
font-family: "Segoe UI", sans-serif;
font-size: 1.3rem;
font-weight: 800;
color: $text;
margin: 0 0 6px;
}
.loginSub {
font-family: "Segoe UI", sans-serif;
font-size: 0.82rem;
color: $muted;
margin: 0 0 28px;
}
.loginForm { display: flex; flex-direction: column; gap: 12px; }
.loginInput {
width: 100%;
padding: 12px 16px;
border: 2px solid $border;
border-radius: $radius-md;
font-size: 0.95rem;
font-family: "Segoe UI", sans-serif;
outline: none;
transition: border-color $transition;
box-sizing: border-box;
&:focus { border-color: $orange; }
&.inputError { border-color: $danger; }
}
.errorMsg {
font-family: "Segoe UI", sans-serif;
font-size: 0.78rem;
color: $danger;
margin: -4px 0 0;
text-align: left;
}
.loginBtn {
padding: 12px;
background: $orange;
color: $white;
border: none;
border-radius: $radius-md;
font-weight: 700;
font-size: 0.95rem;
font-family: "Segoe UI", sans-serif;
cursor: pointer;
transition: background $transition;
&:hover { background: $orange-dark; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
.shake { animation: shake 0.45s ease; }
// ── Loading ───────────────────────────────────────────────────
.loadingScreen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", sans-serif;
font-size: 1rem;
color: $muted;
}
// ── Admin layout ──────────────────────────────────────────────
.adminWrapper {
font-family: "Segoe UI", sans-serif;
min-height: 100vh;
background: $bg;
color: $text;
display: flex;
flex-direction: column;
padding-bottom: 80px;
}
// ── Header ────────────────────────────────────────────────────
.adminHeader {
background: $white;
border-bottom: 1px solid $border;
padding: 16px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
flex-wrap: wrap;
}
.adminHeaderLeft {
display: flex;
align-items: center;
gap: 14px;
}
.adminHeaderIcon { font-size: 1.8rem; }
.adminHeaderTitle {
font-size: 1.1rem;
font-weight: 800;
margin: 0;
}
.adminHeaderSub {
font-size: 0.72rem;
color: $muted;
margin: 0;
}
.adminHeaderRight {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.savedBadge {
background: lighten($success, 58%);
color: $success;
border: 1px solid lighten($success, 40%);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.72rem;
font-weight: 700;
}
.btnSaveHeader {
padding: 8px 18px;
background: $orange;
color: $white;
border: none;
border-radius: $radius-sm;
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
transition: background $transition;
&:hover { background: $orange-dark; }
}
.btnLogout {
padding: 8px 18px;
background: $white;
color: $muted;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-weight: 600;
font-size: 0.82rem;
cursor: pointer;
transition: color $transition, border-color $transition;
&:hover { color: $danger; border-color: $danger; }
}
// ── Tab bar ───────────────────────────────────────────────────
.tabBar {
display: flex;
gap: 0;
background: $white;
border-bottom: 2px solid $border;
padding: 0 28px;
}
.tabBtn {
padding: 12px 24px;
border: none;
background: none;
font-size: 0.85rem;
font-weight: 600;
color: $muted;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: color $transition, border-color $transition;
&:hover { color: $orange; }
&.tabActive {
color: $orange;
border-bottom-color: $orange;
}
}
// ── Products tab body ─────────────────────────────────────────
.adminBody {
display: grid;
grid-template-columns: 220px 1fr;
flex: 1;
@media (max-width: 700px) { grid-template-columns: 1fr; }
}
// ── Sidebar ───────────────────────────────────────────────────
.sidebar {
background: $white;
border-right: 1px solid $border;
padding: 20px 12px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: calc(100vh - 200px);
}
.sidebarLabel {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $muted;
margin: 0 0 8px 6px;
}
.sidebarItem {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
border: none;
background: none;
border-radius: $radius-sm;
font-size: 0.8rem;
font-weight: 500;
color: $text;
cursor: pointer;
transition: background $transition;
&:hover { background: $bg; }
&.sidebarActive {
background: lighten($orange, 44%);
color: $orange;
font-weight: 700;
border-left: 3px solid $orange;
padding-left: 9px;
}
}
// ── Content ───────────────────────────────────────────────────
.content {
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
// ── Field ─────────────────────────────────────────────────────
.fieldRow {
display: flex;
align-items: center;
gap: 12px;
}
.fieldLabel {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $muted;
white-space: nowrap;
min-width: 120px;
}
.fieldInput {
flex: 1;
max-width: 360px;
padding: 9px 14px;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-size: 0.88rem;
font-family: "Segoe UI", sans-serif;
outline: none;
box-sizing: border-box;
transition: border-color $transition;
&:focus { border-color: $orange; }
}
// ── Body type pills row ───────────────────────────────────────
.bodyTypeRow {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.bodyTypePills {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
}
.bodyPill {
padding: 6px 14px;
border-radius: 20px;
border: 1.5px solid $border;
background: $bg;
color: $text;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: border-color $transition, background $transition, color $transition;
&:hover { border-color: $orange; }
&.bodyPillActive {
border-color: $orange;
background: lighten($orange, 44%);
color: $orange;
}
}
.bodyPillIcon {
width: 20px;
height: 20px;
margin-right: 8px;
object-fit: contain;
}
.bodyPillEmoji {
margin-right: 8px;
}
// ── Package tabs ──────────────────────────────────────────────
.pkgTabs {
display: flex;
gap: 4px;
border-bottom: 2px solid $border;
padding-bottom: 0;
}
.pkgTab {
padding: 8px 20px;
font-size: 0.78rem;
font-weight: 700;
border: none;
background: none;
color: $muted;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color $transition, border-color $transition;
&:hover { color: $orange; }
&.pkgActive { color: $orange; border-bottom-color: $orange; }
}
// ── Table ─────────────────────────────────────────────────────
.tableWrapper {
background: $white;
border-radius: $radius-md;
border: 1px solid $border;
overflow: hidden;
}
.table { width: 100%; border-collapse: collapse; }
.th {
text-align: left;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: $muted;
padding: 10px 14px;
background: $bg;
border-bottom: 2px solid $border;
}
.thNum { width: 110px; }
.tr {
&:not(:last-child) { border-bottom: 1px solid $border; }
&:hover { background: rgba($orange, 0.025); }
}
.td { padding: 8px 10px; font-size: 0.82rem; vertical-align: middle; }
.tdTotal { font-weight: 700; color: $orange-dark; white-space: nowrap; }
.cellInput {
width: 100%;
padding: 7px 10px;
border: 1.5px solid $border;
border-radius: $radius-sm;
font-size: 0.82rem;
font-family: "Segoe UI", sans-serif;
outline: none;
box-sizing: border-box;
transition: border-color $transition;
&:focus { border-color: $orange; }
}
.cellNum { text-align: right; }
.emptyRow {
text-align: center;
padding: 24px;
font-size: 0.82rem;
color: $muted;
font-style: italic;
}
.btnAdd {
align-self: flex-start;
background: none;
border: 2px dashed $orange;
color: $orange;
border-radius: $radius-md;
padding: 9px 20px;
font-weight: 700;
font-size: 0.82rem;
font-family: "Segoe UI", sans-serif;
cursor: pointer;
transition: background $transition;
&:hover { background: lighten($orange, 46%); }
}
.btnDel {
background: none;
border: none;
color: $danger;
cursor: pointer;
font-size: 0.9rem;
padding: 4px 8px;
border-radius: $radius-sm;
transition: background $transition;
&:hover { background: lighten($danger, 46%); }
}
// ── Body Types tab ────────────────────────────────────────────
.bodyTypesPage {
padding: 28px;
display: flex;
flex-direction: column;
gap: 24px;
}
.bodyTypesHeader { display: flex; flex-direction: column; gap: 6px; }
.bodyTypesTitle {
font-size: 1.1rem;
font-weight: 800;
margin: 0;
}
.bodyTypesSub {
font-size: 0.82rem;
color: $muted;
margin: 0;
max-width: 540px;
}
.bodyTypesGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.bodyTypeCard {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.imageUploader {
position: relative;
background: #f9f9f9;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
height: 120px;
}
.iconPreview {
max-width: 100%;
max-height: 100%;
object-fit: contain;
font-size: 48px;
}
.uploadLabel {
position: absolute;
bottom: 8px;
right: 8px;
cursor: pointer;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.imageUploader:hover .uploadLabel {
opacity: 1;
}
// ── FIX: bodyTypeCardBottom — input görünür + delete butonu hizalı ──
.bodyTypeCardBottom {
padding: 10px;
display: flex;
align-items: center;
gap: 8px;
background: white;
// Global .fieldInput'taki max-width:360px'i burada ezip
// flex container içinde düzgün genişlemesini sağlıyoruz
.fieldInput {
flex: 1;
min-width: 0; // flex shrink için zorunlu
max-width: none; // ← Ana sorun buydu
}
// Silme butonu sabit genişlikte, flex'ten etkilenmesin
.btnDel {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
}
}
.bodyTypeAddCard {
background: none;
border: 2px dashed $border;
border-radius: $radius-md;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
color: $muted;
font-size: 0.85rem;
font-weight: 600;
transition: border-color $transition, color $transition;
min-height: 100px;
span:first-child { font-size: 1.8rem; }
&:hover { border-color: $orange; color: $orange; }
}
// ── Save hint ─────────────────────────────────────────────────
.saveHint {
background: lighten($orange, 46%);
border: 1px solid lighten($orange, 30%);
border-radius: $radius-md;
padding: 14px 18px;
font-size: 0.82rem;
color: darken($orange, 10%);
max-width: 600px;
p { margin: 0; line-height: 1.6; }
code {
background: rgba($orange, 0.12);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.78rem;
}
}
// ── Floating save button ──────────────────────────────────────
.floatSave {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
}
.btnSaveFloat {
padding: 12px 24px;
background: $orange;
color: $white;
border: none;
border-radius: $radius-lg;
font-weight: 700;
font-size: 0.88rem;
font-family: "Segoe UI", sans-serif;
cursor: pointer;
box-shadow: 0 6px 20px rgba($orange, 0.45);
transition: background $transition, transform $transition;
&:hover {
background: $orange-dark;
transform: translateY(-2px);
}
}

View File

@@ -0,0 +1,435 @@
import { useState, useEffect, useRef } from "react";
import styles from "./Adminpage.module.scss";
// ─── PASSWORD — change this ───────────────────────────────────────────────────
const ADMIN_PASSWORD = "shumoff2024";
// ─── LOGIN ────────────────────────────────────────────────────────────────────
function LoginScreen({ onLogin }) {
const [input, setInput] = useState("");
const [error, setError] = useState(false);
const [shake, setShake] = useState(false);
function handleSubmit(e) {
e.preventDefault();
if (input === ADMIN_PASSWORD) {
onLogin();
} else {
setError(true);
setShake(true);
setTimeout(() => setShake(false), 500);
}
}
return (
<div className={styles.loginWrapper}>
<div className={`${styles.loginCard} ${shake ? styles.shake : ""}`}>
<div className={styles.loginLogo}></div>
<h1 className={styles.loginTitle}>Панель администратора</h1>
<p className={styles.loginSub}>Введите пароль для доступа</p>
<form onSubmit={handleSubmit} className={styles.loginForm}>
<input
type="password"
className={`${styles.loginInput} ${error ? styles.inputError : ""}`}
placeholder="Пароль"
value={input}
onChange={(e) => { setInput(e.target.value); setError(false); }}
autoFocus
/>
{error && <p className={styles.errorMsg}>Неверный пароль. Попробуйте ещё раз.</p>}
<button type="submit" className={styles.loginBtn}>Войти</button>
</form>
</div>
</div>
);
}
// ─── TABS ─────────────────────────────────────────────────────────────────────
const ADMIN_TABS = [
{ id: "products", label: "📦 Товары и цены" },
{ id: "bodytypes", label: "🚗 Типы кузова" },
];
// ─── ADMIN PANEL ─────────────────────────────────────────────────────────────
function AdminPanel({ onLogout }) {
const [data, setData] = useState(null);
const [tab, setTab] = useState("products");
const [zone, setZone] = useState(null);
const [bodyType, setBodyType] = useState(null);
const [pkg, setPkg] = useState(null);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Load data.json from /public
useEffect(() => {
fetch("/frontend-api/data")
.then((r) => r.json())
.then((d) => {
setData(d);
setZone(Object.keys(d.zones)[0]);
setBodyType(d.bodyTypes[0]?.id);
setPkg(d.packages[0]);
setLoading(false);
})
.catch((e) => { setError(e.message); setLoading(false); });
}, []);
// ── helpers ────────────────────────────────────────────────
function updateData(fn) {
setData((prev) => {
const next = JSON.parse(JSON.stringify(prev));
fn(next);
return next;
});
}
// Products tab
function getProducts() {
return data?.zones?.[zone]?.products?.[bodyType]?.[pkg] || [];
}
function setProducts(products) {
updateData((d) => {
if (!d.zones[zone].products[bodyType]) d.zones[zone].products[bodyType] = {};
d.zones[zone].products[bodyType][pkg] = products;
});
}
function updateProduct(idx, field, val) {
const ps = JSON.parse(JSON.stringify(getProducts()));
if (field === "price" || field === "qty") ps[idx][field] = Number(val) || 0;
else ps[idx][field] = val;
setProducts(ps);
}
function deleteProduct(idx) {
const ps = JSON.parse(JSON.stringify(getProducts()));
ps.splice(idx, 1);
setProducts(ps);
}
function addProduct() {
const ps = JSON.parse(JSON.stringify(getProducts()));
ps.push({ name: "Новый товар", price: 0, qty: 1, unit: "Л" });
setProducts(ps);
}
// Body types tab
function updateBodyType(idx, field, val) {
updateData((d) => { d.bodyTypes[idx][field] = val; });
}
function deleteBodyType(idx) {
const bt = data.bodyTypes[idx];
updateData((d) => {
d.bodyTypes.splice(idx, 1);
// remove products for this body type in all zones
Object.values(d.zones).forEach((z) => { delete z.products[bt.id]; });
});
if (bodyType === bt.id) setBodyType(data.bodyTypes[0]?.id);
}
function addBodyType() {
const newId = `body_${Date.now()}`;
updateData((d) => {
d.bodyTypes.push({ id: newId, label: "Новый тип", icon: "🚗" });
});
}
// Zone label
function updateZoneLabel(val) {
updateData((d) => { d.zones[zone].label = val; });
}
function handleImageUpload(e, idx) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
updateBodyType(idx, "image", event.target.result);
updateBodyType(idx, "icon", null); // Eski icon verisini temizle
};
reader.readAsDataURL(file);
}
// ── Save = download updated data.json ──────────────────────
// Since there's no backend, admin downloads the JSON and replaces public/data.json
async function handleSave() {
const res = await fetch("/frontend-api/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
if (!result.ok) { alert("Hata: " + result.error); return; }
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
// ── Render ─────────────────────────────────────────────────
if (loading) return <div className={styles.loadingScreen}>Загрузка...</div>;
if (error) return <div className={styles.loadingScreen}>Ошибка: {error}</div>;
const products = getProducts();
return (
<div className={styles.adminWrapper}>
{/* Header */}
<header className={styles.adminHeader}>
<div className={styles.adminHeaderLeft}>
<span className={styles.adminHeaderIcon}></span>
<div>
<h1 className={styles.adminHeaderTitle}>Панель администратора</h1>
<p className={styles.adminHeaderSub}>Управление товарами, ценами и типами кузова</p>
</div>
</div>
<div className={styles.adminHeaderRight}>
{saved && (
<span className={styles.savedBadge}>
Сохранено
</span>
)}
<button className={styles.btnSaveHeader} onClick={handleSave}>
💾 Сохранить
</button>
<button className={styles.btnLogout} onClick={onLogout}>Выйти</button>
</div>
</header>
{/* Tab bar */}
<div className={styles.tabBar}>
{ADMIN_TABS.map((t) => (
<button
key={t.id}
className={`${styles.tabBtn} ${tab === t.id ? styles.tabActive : ""}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{/* ── PRODUCTS TAB ─────────────────────────────────────── */}
{tab === "products" && (
<div className={styles.adminBody}>
{/* Sidebar: zones */}
<aside className={styles.sidebar}>
<p className={styles.sidebarLabel}>Зоны</p>
{Object.entries(data.zones).map(([zid, z]) => (
<button
key={zid}
className={`${styles.sidebarItem} ${zone === zid ? styles.sidebarActive : ""}`}
onClick={() => setZone(zid)}
>
{z.label}
</button>
))}
</aside>
{/* Main content */}
<main className={styles.content}>
{/* Zone label */}
<div className={styles.fieldRow}>
<label className={styles.fieldLabel}>Название зоны</label>
<input
className={styles.fieldInput}
value={data.zones[zone]?.label || ""}
onChange={(e) => updateZoneLabel(e.target.value)}
/>
</div>
{/* Body type selector */}
<div className={styles.bodyTypeRow}>
<span className={styles.fieldLabel}>Тип кузова</span>
<div className={styles.bodyTypePills}>
{data.bodyTypes.map((b) => (
<button
key={b.id}
className={`${styles.bodyPill} ${bodyType === b.id ? styles.bodyPillActive : ""}`}
onClick={() => setBodyType(b.id)}
>
{b.image ? <img src={b.image} alt={b.label} className={styles.bodyPillIcon} /> : (b.icon && <span className={styles.bodyPillEmoji}>{b.icon}</span>)}
{b.label}
</button>
))}
</div>
</div>
{/* Package tabs */}
<div className={styles.pkgTabs}>
{data.packages.map((p) => (
<button
key={p}
className={`${styles.pkgTab} ${pkg === p ? styles.pkgActive : ""}`}
onClick={() => setPkg(p)}
>
{p}
</button>
))}
</div>
{/* Products table */}
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Название товара</th>
<th className={`${styles.th} ${styles.thNum}`}>Цена (m)</th>
<th className={`${styles.th} ${styles.thNum}`}>Кол-во</th>
<th className={`${styles.th} ${styles.thNum}`}>Ед.</th>
<th className={`${styles.th} ${styles.thNum}`}>Итого</th>
<th className={styles.th}></th>
</tr>
</thead>
<tbody>
{products.map((p, i) => (
<tr key={i} className={styles.tr}>
<td className={styles.td}>
<input
className={styles.cellInput}
value={p.name}
onChange={(e) => updateProduct(i, "name", e.target.value)}
/>
</td>
<td className={styles.td}>
<input
className={`${styles.cellInput} ${styles.cellNum}`}
type="number" min="0"
value={p.price}
onChange={(e) => updateProduct(i, "price", e.target.value)}
/>
</td>
<td className={styles.td}>
<input
className={`${styles.cellInput} ${styles.cellNum}`}
type="number" min="0"
value={p.qty}
onChange={(e) => updateProduct(i, "qty", e.target.value)}
/>
</td>
<td className={styles.td}>
<input
className={`${styles.cellInput} ${styles.cellNum}`}
value={p.unit}
onChange={(e) => updateProduct(i, "unit", e.target.value)}
/>
</td>
<td className={`${styles.td} ${styles.tdTotal}`}>
{(p.price * p.qty).toLocaleString("ru")} m
</td>
<td className={styles.td}>
<button className={styles.btnDel} onClick={() => deleteProduct(i)}></button>
</td>
</tr>
))}
{products.length === 0 && (
<tr>
<td colSpan={6} className={styles.emptyRow}>
Нет товаров для этой комбинации
</td>
</tr>
)}
</tbody>
</table>
</div>
<button className={styles.btnAdd} onClick={addProduct}>
+ Добавить товар
</button>
</main>
</div>
)}
{/* ── BODY TYPES TAB ───────────────────────────────────── */}
{tab === "bodytypes" && (
<div className={styles.bodyTypesPage}>
<div className={styles.bodyTypesHeader}>
<h2 className={styles.bodyTypesTitle}>Типы кузова</h2>
<p className={styles.bodyTypesSub}>
Добавляйте, удаляйте и редактируйте типы кузова. Изменения применяются
ко всем зонам и пакетам.
</p>
</div>
<div className={styles.bodyTypesGrid}>
{data.bodyTypes.map((b, i) => (
<div key={b.id} className={styles.bodyTypeCard}>
<div className={styles.imageUploader}>
{b.image ? (
<img src={b.image} alt="Preview" className={styles.iconPreview} />
) : (
b.icon && <span className={styles.iconPreview}>{b.icon}</span>
)}
<label className={styles.uploadLabel}>
<span>{b.image || b.icon ? 'Изменить' : 'Загрузить'}</span>
<input
type="file"
accept="image/png, image/jpeg, image/svg+xml"
onChange={(e) => handleImageUpload(e, i)}
style={{ display: 'none' }}
/>
</label>
</div>
<div className={styles.bodyTypeCardBottom}>
<input
className={styles.fieldInput}
value={b.label}
onChange={(e) => updateBodyType(i, "label", e.target.value)}
placeholder="Название типа"
/>
<button
className={styles.btnDel}
onClick={() => {
if (window.confirm(`Удалить тип "${b.label}"? Все товары для этого типа будут удалены.`)) {
deleteBodyType(i);
}
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
</div>
))}
{/* Add new */}
<button className={styles.bodyTypeAddCard} onClick={addBodyType}>
<span>+</span>
<span>Добавить тип</span>
</button>
</div>
<div className={styles.saveHint}>
<p>
После всех изменений нажмите <strong>«Сохранить»</strong> в шапке.
</p>
</div>
</div>
)}
{/* Floating save */}
<div className={styles.floatSave}>
<button className={styles.btnSaveFloat} onClick={handleSave}>
💾 Сохранить
</button>
</div>
</div>
);
}
// ─── PAGE EXPORT ─────────────────────────────────────────────────────────────
export default function AdminPage() {
const [authed, setAuthed] = useState(false);
return authed
? <AdminPanel onLogout={() => setAuthed(false)} />
: <LoginScreen onLogin={() => setAuthed(true)} />;
}

View File

@@ -16,10 +16,9 @@
.cartHeader { .cartHeader {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 12px;
justify-content: space-between; justify-content: space-between;
background-color: #f3f4f6; background-color: #f3f4f6;
padding-bottom: 15px; padding-top: 10px;
h2 { h2 {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
@@ -27,11 +26,11 @@
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
} }
} }
} }
} }
.cartProducts { .cartProducts {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -152,6 +151,7 @@
@media screen and (max-width: 720px) { @media screen and (max-width: 720px) {
flex-direction: row-reverse; flex-direction: row-reverse;
justify-content: space-between; justify-content: space-between;
gap:10px;
} }
.price { .price {
@@ -226,7 +226,7 @@
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
width: 100%; width: 100%;
position: static; position: static;
margin-top: 16px; margin-bottom: 16px;
} }
h3 { h3 {
@@ -524,3 +524,106 @@
} }
} }
} }
.pendingPriceBadgeWrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.pendingPriceBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #faeeda;
border: 0.5px solid #ef9f27;
color: #854f0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.pendingPriceTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-background-primary, #ffffff);
border: 0.5px solid var(--color-border-secondary, #e2e2e2);
border-radius: var(--border-radius-md, 6px);
padding: 8px 12px;
width: 220px;
font-size: 13px;
color: var(--color-text-primary, #333333);
line-height: 1.5;
z-index: 100;
white-space: normal;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@media (max-width: 767px) {
display: none;
}
strong {
display: block;
margin-bottom: 4px;
color: var(--color-text-primary, #000000);
}
}
:global {
.pending-price-modal {
.ant-modal-content {
border-radius: 12px;
padding: 24px;
@media (max-width: 767px) {
padding: 20px;
}
}
.ant-modal-header {
margin-bottom: 12px;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
@media (max-width: 767px) {
font-size: 16px;
}
}
}
.ant-modal-body {
p {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0;
@media (max-width: 767px) {
font-size: 13px;
}
}
}
.ant-modal-footer {
margin-top: 20px;
.ant-btn-primary {
background-color: #888888;
border-color: #888888;
border-radius: 6px;
height: 36px;
padding: 0 20px;
font-weight: 500;
&:hover {
background-color: #666666;
border-color: #666666;
}
}
}
}
}

View File

@@ -2,13 +2,10 @@ import React, { useState, useRef, useEffect, useMemo } from "react";
import styles from "./CartPage.module.scss"; import styles from "./CartPage.module.scss";
import { FaTrashAlt } from "react-icons/fa"; import { FaTrashAlt } from "react-icons/fa";
import Checkout from "../../components/Checkout"; import Checkout from "../../components/Checkout";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Modal } from "antd"; import { Modal } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmptyCartState from "./emptyCart"; import EmptyCartState from "./emptyCart";
import { import {
useGetCartQuery,
useAddToCartMutation,
useRemoveFromCartMutation, useRemoveFromCartMutation,
useUpdateCartItemMutation, useUpdateCartItemMutation,
useCleanCartMutation, useCleanCartMutation,
@@ -16,10 +13,11 @@ import {
import { useCart } from "../../app/api/useCart"; import { useCart } from "../../app/api/useCart";
import { DecreaseIcon, IncreaseIcon } from "../../components/Icons"; import { DecreaseIcon, IncreaseIcon } from "../../components/Icons";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const TruncatedDescription = ({ description, maxLength = 100 }) => { const TruncatedDescription = ({ description, maxLength = 100 }) => {
const [isExpanded, setIsExpanded] = useState(false);
const stripHtml = (html) => { const stripHtml = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html"); const doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent || ""; return doc.body.textContent || "";
@@ -32,11 +30,9 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
<div className={styles.truncatedDescription}> <div className={styles.truncatedDescription}>
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: isExpanded __html: shouldTruncate
? description ? description.substring(0, maxLength) + "..."
: shouldTruncate : description,
? description.substring(0, maxLength) + "..."
: description,
}} }}
/> />
</div> </div>
@@ -44,20 +40,16 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
}; };
const CartPage = () => { const CartPage = () => {
const { cartData, cartItems, isLoading, isError, error } = useCart(); const { cartData, cartItems, isLoading } = useCart();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [checkoutStores, setCheckoutStores] = useState({}); const [checkoutStores, setCheckoutStores] = useState({});
const [addToCart] = useAddToCartMutation();
const [removeFromCart] = useRemoveFromCartMutation(); const [removeFromCart] = useRemoveFromCartMutation();
const [updateCartItem] = useUpdateCartItemMutation(); const [updateCartItem] = useUpdateCartItemMutation();
const [cleanCart] = useCleanCartMutation(); const [cleanCart] = useCleanCartMutation();
const [isExpanded, setIsExpanded] = useState(false);
const expandedRef = useRef(null);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [emptyCartModalVisible, setEmptyCartModalVisible] = useState(false); const [emptyCartModalVisible, setEmptyCartModalVisible] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [localQuantities, setLocalQuantities] = useState({}); const [localQuantities, setLocalQuantities] = useState({});
const [pendingQuantities, setPendingQuantities] = useState({}); const [pendingQuantities, setPendingQuantities] = useState({});
const [loadingItems, setLoadingItems] = useState({}); const [loadingItems, setLoadingItems] = useState({});
@@ -70,43 +62,35 @@ const CartPage = () => {
width: 400, width: 400,
}; };
// Convert grouped data to stores array
const stores = useMemo(() => { const stores = useMemo(() => {
return Object.entries(cartData) return Object.entries(cartData)
.map(([storeSlug, items]) => { .map(([storeSlug, items]) => {
if (!items || !items.length) return null; if (!items?.length) return null;
// Get store info from first item
const storeInfo = items[0]?.product?.channel?.[0]; const storeInfo = items[0]?.product?.channel?.[0];
return { return {
id: storeInfo?.id || storeSlug, id: storeInfo?.id || storeSlug,
name: storeInfo?.name || storeSlug, name: storeInfo?.name || storeSlug,
slug: storeSlug, slug: storeSlug,
shipping_price: storeInfo?.shipping_price, shipping_price: storeInfo?.shipping_price,
items: items, items,
}; };
}) })
.filter(Boolean); .filter(Boolean);
}, [cartData]); }, [cartData]);
// ✅ Initialize local quantities from cart items
useEffect(() => { useEffect(() => {
const newLocalQuantities = {}; const newLocal = {};
const newPendingQuantities = {}; const newPending = {};
cartItems.forEach((item) => { cartItems.forEach((item) => {
const productId = item.product.id; const id = item.product.id;
const quantity = parseInt(item.product_quantity, 10) || 0; const qty = parseInt(item.product_quantity, 10) || 0;
newLocalQuantities[productId] = quantity; newLocal[id] = qty;
newPendingQuantities[productId] = quantity; newPending[id] = qty;
}); });
setLocalQuantities(newLocal);
setLocalQuantities(newLocalQuantities); setPendingQuantities(newPending);
setPendingQuantities(newPendingQuantities);
}, [cartItems]); }, [cartItems]);
// ✅ Debounced Cart Update - Her ürün için ayrı debounce
useEffect(() => { useEffect(() => {
const timers = {}; const timers = {};
@@ -114,141 +98,94 @@ const CartPage = () => {
const serverItem = cartItems.find( const serverItem = cartItems.find(
(item) => String(item.product.id) === String(productId), (item) => String(item.product.id) === String(productId),
); );
const serverQuantity = serverItem const serverQty = serverItem
? parseInt(serverItem.product_quantity, 10) ? parseInt(serverItem.product_quantity, 10)
: 0; : 0;
const pendingQuantity = pendingQuantities[productId]; const pendingQty = pendingQuantities[productId];
// Değişiklik yoksa veya 0 ise (Delete modalı tetikler) bir şey yapma
if ( if (
pendingQuantity === undefined || pendingQty === undefined ||
pendingQuantity === serverQuantity || pendingQty === serverQty ||
pendingQuantity <= 0 pendingQty <= 0
) { )
return; return;
}
timers[productId] = setTimeout(async () => { timers[productId] = setTimeout(async () => {
try { try {
setLoadingItems((prev) => ({ ...prev, [productId]: true })); setLoadingItems((prev) => ({ ...prev, [productId]: true }));
await updateCartItem({ await updateCartItem({ productId, quantity: pendingQty }).unwrap();
productId, } catch {
quantity: pendingQuantity, setLocalQuantities((prev) => ({ ...prev, [productId]: serverQty }));
}).unwrap(); setPendingQuantities((prev) => ({ ...prev, [productId]: serverQty }));
} catch (error) {
console.error("Failed to update cart:", error);
// Hata durumunda rollback
setLocalQuantities((prev) => ({
...prev,
[productId]: serverQuantity,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: serverQuantity,
}));
} finally { } finally {
setLoadingItems((prev) => ({ ...prev, [productId]: false })); setLoadingItems((prev) => ({ ...prev, [productId]: false }));
} }
}, 500); }, 500);
}); });
return () => { return () => Object.values(timers).forEach(clearTimeout);
Object.values(timers).forEach((timer) => clearTimeout(timer));
};
}, [pendingQuantities, cartItems, updateCartItem]); }, [pendingQuantities, cartItems, updateCartItem]);
const handleQuantityIncrease = (productId) => (event) => { const handleQuantityIncrease = (productId) => (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (loadingItems[productId]) return; if (loadingItems[productId]) return;
const item = cartItems.find((item) => item.product.id === productId); const item = cartItems.find((i) => i.product.id === productId);
if (!item) return; if (!item || localQuantities[productId] >= item.product.stock) return;
if (localQuantities[productId] >= item.product.stock) { const newQty = (localQuantities[productId] || 0) + 1;
return; setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
} setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
const newQuantity = (localQuantities[productId] || 0) + 1;
setLocalQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
}; };
const handleQuantityDecrease = (productId) => (event) => { const handleQuantityDecrease = (productId) => (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (loadingItems[productId]) return; if (loadingItems[productId]) return;
const currentQuantity = localQuantities[productId] || 0; const currentQty = localQuantities[productId] || 0;
if (currentQty <= 1) {
if (currentQuantity <= 1) {
showDeleteConfirm(productId); showDeleteConfirm(productId);
return; return;
} }
const newQuantity = currentQuantity - 1; const newQty = currentQty - 1;
setLocalQuantities((prev) => ({ setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
...prev, setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
[productId]: newQuantity,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
}; };
const calculateStoreTotal = (storeItems) => { const getStoreShippingPrice = (store) =>
return storeItems.reduce((sum, item) => { store.shipping_price != null ? parseFloat(store.shipping_price) : 20;
const itemPrice = parseFloat(item.product.price_amount) || 0;
const itemQuantity = parseInt(item.product_quantity, 10) || 0; // Store içinde fiyatsız ürün var mı?
return sum + itemPrice * itemQuantity; const storeHasZeroPriceItem = (storeItems) =>
storeItems.some((item) => isPriceZero(item.product.price_amount));
const calculateStoreTotal = (storeItems) =>
storeItems.reduce((sum, item) => {
return (
sum +
(parseFloat(item.product.price_amount) || 0) *
(parseInt(item.product_quantity, 10) || 0)
);
}, 0); }, 0);
};
const getStoreShippingPrice = (store) => { const handleCheckout = (storeId) =>
return store.shipping_price !== null && store.shipping_price !== undefined
? parseFloat(store.shipping_price)
: 20;
};
const handleCheckout = (storeId) => {
setCheckoutStores((prev) => ({ ...prev, [storeId]: true })); setCheckoutStores((prev) => ({ ...prev, [storeId]: true }));
};
const handleBackToCart = (storeId) => { const handleBackToCart = (storeId) =>
setCheckoutStores((prev) => ({ ...prev, [storeId]: false })); setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
};
const handleOrderSubmit = async (storeId, storeItems) => { const handleOrderSubmit = async (storeId) => {
if (checkoutStores[storeId] && checkoutRefs.current[storeId]) { if (checkoutStores[storeId] && checkoutRefs.current[storeId]) {
const success = await checkoutRefs.current[storeId](); const success = await checkoutRefs.current[storeId]();
if (success) { if (success) setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
}
} else { } else {
handleCheckout(storeId); handleCheckout(storeId);
} }
}; };
useEffect(() => {
const handleClickOutside = (event) => {
if (expandedRef.current && !expandedRef.current.contains(event.target)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showDeleteConfirm = (productId) => { const showDeleteConfirm = (productId) => {
setItemToDelete(productId); setItemToDelete(productId);
setDeleteModalVisible(true); setDeleteModalVisible(true);
@@ -258,48 +195,41 @@ const CartPage = () => {
if (itemToDelete) { if (itemToDelete) {
try { try {
await removeFromCart({ productId: itemToDelete }).unwrap(); await removeFromCart({ productId: itemToDelete }).unwrap();
setLocalQuantities((prev) => { setLocalQuantities((prev) => {
const newState = { ...prev }; const s = { ...prev };
delete newState[itemToDelete]; delete s[itemToDelete];
return newState; return s;
}); });
setPendingQuantities((prev) => { setPendingQuantities((prev) => {
const newState = { ...prev }; const s = { ...prev };
delete newState[itemToDelete]; delete s[itemToDelete];
return newState; return s;
}); });
} catch (error) { } catch (e) {
console.error("Failed to remove item:", error); console.error("Failed to remove item:", e);
} }
} }
setDeleteModalVisible(false); setDeleteModalVisible(false);
setItemToDelete(null); setItemToDelete(null);
}; };
const showEmptyCartConfirm = () => {
setEmptyCartModalVisible(true);
};
const handleEmptyCartConfirm = async () => { const handleEmptyCartConfirm = async () => {
try { try {
await cleanCart().unwrap(); await cleanCart().unwrap();
setLocalQuantities({}); setLocalQuantities({});
setPendingQuantities({}); setPendingQuantities({});
setCheckoutStores({}); setCheckoutStores({});
} catch (error) { } catch (e) {
console.error("Failed to clean cart:", error); console.error("Failed to clean cart:", e);
} }
setEmptyCartModalVisible(false); setEmptyCartModalVisible(false);
}; };
const getTotalItemCount = () => { const getTotalItemCount = () =>
return cartItems.reduce( cartItems.reduce(
(sum, item) => sum + parseInt(item.product_quantity, 10), (sum, item) => sum + parseInt(item.product_quantity, 10),
0, 0,
); );
};
return ( return (
<div className={styles.cartContainer}> <div className={styles.cartContainer}>
@@ -339,21 +269,20 @@ const CartPage = () => {
<h2> <h2>
{t("cart.basket")} ({getTotalItemCount()}) {t("cart.basket")} ({getTotalItemCount()})
</h2> </h2>
<div> <button
<button className={styles.deleteBtn}
className={styles.deleteBtn} style={{ padding: "4px 12px" }}
style={{ padding: "4px 12px" }} onClick={() => setEmptyCartModalVisible(true)}
onClick={showEmptyCartConfirm} >
> <FaTrashAlt /> {t("cart.clearCart")}
<FaTrashAlt /> {t("cart.clearCart")} </button>
</button>
</div>
</div> </div>
{stores.map((store) => { {stores.map((store) => {
const shippingPrice = getStoreShippingPrice(store); const shippingPrice = getStoreShippingPrice(store);
const storeTotal = calculateStoreTotal(store.items); const storeTotal = calculateStoreTotal(store.items);
const totalWithShipping = storeTotal + shippingPrice; const totalWithShipping = storeTotal + shippingPrice;
const hasZeroPrice = storeHasZeroPriceItem(store.items);
return ( return (
<div key={store.id} className={styles.storeSection}> <div key={store.id} className={styles.storeSection}>
@@ -363,8 +292,8 @@ const CartPage = () => {
shippingPrice={shippingPrice} shippingPrice={shippingPrice}
productIds={store.items.map((item) => item.product.id)} productIds={store.items.map((item) => item.product.id)}
onBackToCart={() => handleBackToCart(store.id)} onBackToCart={() => handleBackToCart(store.id)}
onPlaceOrder={(placeOrderFn) => { onPlaceOrder={(fn) => {
checkoutRefs.current[store.id] = placeOrderFn; checkoutRefs.current[store.id] = fn;
}} }}
/> />
) : ( ) : (
@@ -391,10 +320,9 @@ const CartPage = () => {
</div> </div>
<div className={styles.priceQuantity}> <div className={styles.priceQuantity}>
<span className={styles.price}> <span className={styles.price}>
{( {isPriceZero(item.product.price_amount)
parseFloat(item.product.price_amount) || 0 ? t("cart.pendingPriceTitle")
).toFixed(2)}{" "} : `${parseFloat(item.product.price_amount).toFixed(2)} m.`}
m.
</span> </span>
<div className={styles.quantityControls}> <div className={styles.quantityControls}>
<button <button
@@ -441,26 +369,38 @@ const CartPage = () => {
</div> </div>
)} )}
{/* ✅ Store Summary - fiyatsız ürün varsa "Baha anyklamak" */}
<div className={styles.storeSummary}> <div className={styles.storeSummary}>
<div className={styles.cartContent}> <div className={styles.cartContent}>
<h3> <h3>
{store.name} - {t("cart.basket")}: {store.name} - {t("cart.basket")}:
</h3> </h3>
<div className={styles.summaryRow}> {hasZeroPrice ? (
<span>{t("cart.price")}:</span> <div className={styles.summaryRow}>
<span>{storeTotal.toFixed(2)} m.</span> <span>{t("cart.total")}:</span>
</div> <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<div className={styles.summaryRow}> {t("cart.pendingPriceTitle")} <PendingPriceBadge />
<span>{t("cart.delivery")}:</span> </span>
<span>{shippingPrice.toFixed(2)} m.</span> </div>
</div> ) : (
<div className={styles.summaryRow}> <>
<span>{t("cart.total")}:</span> <div className={styles.summaryRow}>
<span>{totalWithShipping.toFixed(2)} m.</span> <span>{t("cart.price")}:</span>
</div> <span>{storeTotal.toFixed(2)} m.</span>
</div>
<div className={styles.summaryRow}>
<span>{t("cart.delivery")}:</span>
<span>{shippingPrice.toFixed(2)} m.</span>
</div>
<div className={styles.summaryRow}>
<span>{t("cart.total")}:</span>
<span>{totalWithShipping.toFixed(2)} m.</span>
</div>
</>
)}
</div> </div>
<button <button
onClick={() => handleOrderSubmit(store.id, store.items)} onClick={() => handleOrderSubmit(store.id)}
className={styles.checkoutBtn} className={styles.checkoutBtn}
> >
{checkoutStores[store.id] {checkoutStores[store.id]
@@ -472,7 +412,6 @@ const CartPage = () => {
); );
})} })}
</div> </div>
{/* Mobile sticky summary */} {/* Mobile sticky summary */}
{/* <div className={styles.container}> {/* <div className={styles.container}>
<div className={styles.summaryCard} ref={expandedRef}> <div className={styles.summaryCard} ref={expandedRef}>

View File

@@ -1,3 +1,96 @@
.sortingSection {
display: flex;
flex-direction: column;
gap: 8px;
.sortingTitle {
font-size: 14px;
color: #333;
font-weight: 600;
margin: 0 0 8px 0;
}
.sortingButtonsContainer {
display: flex;
flex-direction: column;
gap: 6px;
.sortingBtn {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 5px;
background: #fff;
color: #333;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
text-align: center;
&:hover {
border-color: #d32824;
background-color: #fff5f5;
}
&.activeSorting {
background-color: #d32824;
color: #fff;
border-color: #d32824;
font-weight: 600;
}
}
}
}
.sortingContainer {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
.sortingLabel {
font-size: 14px;
color: #888;
}
}
.pricePresetsContainer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
.pricePresetBtn {
padding: 7px 10px;
border: 1px solid #d1d5db;
border-radius: 5px;
background: #fff;
color: #333;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
border-color: #d32824;
background-color: #fff5f5;
}
&.activePreset {
background-color: #d32824;
color: #fff;
border-color: #d32824;
font-weight: 600;
}
}
}
.mobilePhoneGrid { .mobilePhoneGrid {
display: flex !important; display: flex !important;
@@ -7,41 +100,65 @@
// Price Filter Styles // Price Filter Styles
.priceFilterContainer { .priceFilterContainer {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; gap: 12px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 16px; margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); padding: 12px;
background-color: #f9f9f9;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
animation: slideDown 0.2s ease-in-out;
} }
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.priceInputGroup { .priceInputGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 4px; gap: 6px;
flex: 1;
min-width: 0;
} }
.priceLabel { .priceLabel {
font-size: 13px; font-size: 12px;
color: #888; color: #666;
margin-bottom: 2px; font-weight: 600;
letter-spacing: 0.3px;
} }
.priceInput { .priceInput {
width: 90px; padding: 8px 10px;
padding: 6px 10px;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
font-size: 15px; font-size: 14px;
background: #fff; background: #fff;
transition: border-color 0.2s; transition: all 0.2s ease;
width: 85%;
&::placeholder {
color: #bbb;
}
} }
.priceInput:focus { .priceInput:focus {
border-color: #6c63ff; border-color: #d32824;
box-shadow: 0 0 0 3px rgba(211, 40, 36, 0.1);
outline: none; outline: none;
} }
.priceDivider { .priceDivider {
font-size: 18px; display: none;
color: #aaa;
font-weight: bold;
margin: 0 6px;
} }
.filtersContainer{ .filtersContainer{
@@ -67,6 +184,16 @@
color: #000000; color: #000000;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease-in-out;
padding: 4px 8px;
border-radius: 6px;
&:hover {
background-color: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transform: translateX(4px);
color: #d32824;
}
} }
label { label {
@@ -282,10 +409,18 @@
} }
} }
.productGrid::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
.productsContainer { .productsContainer {
flex: 1; flex: 1;
.productGrid { .productGrid {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
overflow: hidden !important;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
gap: 20px; gap: 20px;
@@ -364,3 +499,45 @@
} }
} }
} }
.channelHeader {
display: flex;
align-items: center;
gap: 20px;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
.channelLogo {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #eee;
}
.channelInfo {
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #333;
}
}
@media screen and (max-width: 768px) {
padding: 15px;
gap: 15px;
.channelLogo {
width: 60px;
height: 60px;
}
.channelInfo h1 {
font-size: 20px;
}
}
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TiTick } from "react-icons/ti"; import { TiTick } from "react-icons/ti";
import { Divider } from "antd";
import styles from "../CategoryPage.module.scss"; import styles from "../CategoryPage.module.scss";
const CategoryFilters = ({ const CategoryFilters = ({
@@ -18,10 +19,37 @@ const CategoryFilters = ({
onBrandSelect, onBrandSelect,
onBrandDeselect, onBrandDeselect,
onBrandSearchChange, onBrandSearchChange,
sorting = "",
onSortingChange = () => {},
className, className,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const pricePresets = [
{ label: t("category.under50"), min: 0, max: 50 },
{ label: t("category.under100"), min: 0, max: 100 },
{ label: t("category.from50to200"), min: 50, max: 200 },
{ label: t("category.from200to500"), min: 200, max: 500 },
{ label: t("category.from500to1000"), min: 500, max: 1000 },
{ label: t("category.over1000"), min: 1000, max: 999999 },
];
const handlePricePreset = (preset) => {
// Eğer zaten aktifse, sıfırla
if (minPrice === preset.min.toString() && maxPrice === preset.max.toString()) {
onMinPriceChange("");
onMaxPriceChange("");
} else {
onMinPriceChange(preset.min.toString());
onMaxPriceChange(preset.max.toString());
}
};
const sortOptions = [
{ value: "price_amount-ascending", label: t("category.priceLowToHigh") },
{ value: "price_amount-descending", label: t("category.priceHighToLow") },
];
if (searchQuery) return null; if (searchQuery) return null;
return ( return (
@@ -98,6 +126,23 @@ const CategoryFilters = ({
)} )}
<div className={styles.filterSection}> <div className={styles.filterSection}>
<h3>{t("category.price")}</h3> <h3>{t("category.price")}</h3>
<div className={styles.pricePresetsContainer}>
{pricePresets.map((preset, idx) => (
<button
key={idx}
className={`${styles.pricePresetBtn} ${
minPrice === preset.min.toString() && maxPrice === preset.max.toString()
? styles.activePreset
: ""
}`}
onClick={() => handlePricePreset(preset)}
>
{preset.label}
</button>
))}
</div>
<div className={styles.priceFilterContainer}> <div className={styles.priceFilterContainer}>
<div className={styles.priceInputGroup}> <div className={styles.priceInputGroup}>
<span className={styles.priceLabel}>{t("category.minPrice")}</span> <span className={styles.priceLabel}>{t("category.minPrice")}</span>
@@ -123,6 +168,27 @@ const CategoryFilters = ({
/> />
</div> </div>
</div> </div>
<Divider style={{ margin: "12px 0" }} />
<div className={styles.sortingSection}>
<h4 className={styles.sortingTitle}>{t("category.sortBy")}</h4>
<div className={styles.sortingButtonsContainer}>
{sortOptions.map((option) => (
<button
key={option.value}
className={`${styles.sortingBtn} ${sorting === option.value ? styles.activeSorting : ""}`}
onClick={() => onSortingChange(option.value)}
aria-pressed={sorting === option.value}
>
{option.label}
{sorting === option.value && (
<span style={{ marginLeft: 4, fontWeight: "bold" }}></span>
)}
</button>
))}
</div>
</div>
</div> </div>
</aside> </aside>
); );

View File

@@ -5,11 +5,13 @@ import {
useGetFiltersQuery, useGetFiltersQuery,
useLazyGetFiltersQuery, useLazyGetFiltersQuery,
} from "../../../app/api/filtersApi"; } from "../../../app/api/filtersApi";
import { useGetChannelsQuery } from "../../../app/api/channelsApi";
const useCategoryData = ({ const useCategoryData = ({
categoryId, categoryId,
collectionId, collectionId,
brandId, brandId,
channelId,
selectedFilterCategory, selectedFilterCategory,
searchQuery, searchQuery,
}) => { }) => {
@@ -23,8 +25,9 @@ const useCategoryData = ({
if (categoryId) return { category_id: categoryId }; if (categoryId) return { category_id: categoryId };
if (collectionId) return { collection_id: collectionId }; if (collectionId) return { collection_id: collectionId };
if (brandId) return { brand_id: brandId }; if (brandId) return { brand_id: brandId };
if (channelId) return { channel_id: channelId };
return null; return null;
}, [categoryId, collectionId, brandId, selectedFilterCategory, searchQuery]); }, [categoryId, collectionId, brandId, channelId, selectedFilterCategory, searchQuery]);
const { const {
data: filtersData, data: filtersData,
@@ -44,6 +47,19 @@ const useCategoryData = ({
skip: !collectionId, skip: !collectionId,
}); });
const {
data: channelsListData,
isLoading: channelsLoading,
error: channelsError,
} = useGetChannelsQuery({ perPage: 100 }, {
skip: !channelId,
});
const channelData = useMemo(() => {
if (!channelId || !channelsListData?.data) return null;
return channelsListData.data.find(c => String(c.id) === String(channelId));
}, [channelId, channelsListData]);
const isSubCategory = useMemo(() => { const isSubCategory = useMemo(() => {
if (!categoriesData?.data || !categoryId) return false; if (!categoriesData?.data || !categoryId) return false;
@@ -92,8 +108,8 @@ const useCategoryData = ({
setSelectedCategory(category); setSelectedCategory(category);
}, [categoryId, categoriesData]); }, [categoryId, categoriesData]);
const isLoading = filtersLoading || collectionLoading; const isLoading = filtersLoading || collectionLoading || channelsLoading;
const hasError = filtersError || collectionError; const hasError = filtersError || collectionError || channelsError;
return { return {
categoriesData, categoriesData,
@@ -101,6 +117,7 @@ const useCategoryData = ({
isSubCategory, isSubCategory,
filtersData: activeFilters, filtersData: activeFilters,
collectionData, collectionData,
channelData,
isLoading, isLoading,
hasError, hasError,
fetchFilters, fetchFilters,

View File

@@ -1,15 +1,17 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { import {
useGetCategoryProductsQuery,
useLazyGetAllCategoryProductsPaginatedQuery, useLazyGetAllCategoryProductsPaginatedQuery,
useGetCategoryProductsQuery,
} from "../../../app/api/categories"; } from "../../../app/api/categories";
import { useLazyGetBrandProductsQuery } from "../../../app/api/brandsApi"; import { useLazyGetBrandProductsQuery } from "../../../app/api/brandsApi";
import { useLazyGetCollectionProductsPaginatedQuery } from "../../../app/api/collectionsApi"; import { useLazyGetCollectionProductsPaginatedQuery } from "../../../app/api/collectionsApi";
import { useLazyGetChannelProductsQuery } from "../../../app/api/channelsApi"; // EKLE
const useCategoryProducts = ({ const useCategoryProducts = ({
categoryId, categoryId,
collectionId, collectionId,
brandId, brandId,
channelId,
selectedCategory, selectedCategory,
isSubCategory, isSubCategory,
currentPage, currentPage,
@@ -17,296 +19,198 @@ const useCategoryProducts = ({
selectedFilterBrand, selectedFilterBrand,
minPrice, minPrice,
maxPrice, maxPrice,
sorting,
searchQuery, searchQuery,
initialProducts = [],
initialHasMore = true,
}) => { }) => {
const [products, setProducts] = useState([]); const [products, setProducts] = useState(initialProducts);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(initialHasMore);
const [isFetching, setIsFetching] = useState(false);
const isFetchingRef = useRef(false); const activeRequestId = useRef(0);
const lastFetchKeyRef = useRef(null);
const abortControllerRef = useRef(null);
const contextId = useMemo(() => {
const parts = [
selectedFilterCategory && `fcat-${selectedFilterCategory}`,
categoryId && `cat-${categoryId}`,
brandId && `brand-${brandId}`,
collectionId && `col-${collectionId}`,
selectedFilterBrand && `fbrand-${selectedFilterBrand}`,
minPrice && `min-${minPrice}`,
maxPrice && `max-${maxPrice}`,
].filter(Boolean);
return parts.join("|") || "none";
}, [
selectedFilterCategory,
categoryId,
brandId,
collectionId,
selectedFilterBrand,
minPrice,
maxPrice,
]);
const fetchParams = useMemo(
() => ({
page: currentPage,
limit: 6,
brands: selectedFilterBrand || undefined,
min_price: minPrice || undefined,
max_price: maxPrice || undefined,
}),
[currentPage, selectedFilterBrand, minPrice, maxPrice],
);
const fetchKey = `${contextId}-p${currentPage}`;
const shouldUseBaseQuery = const shouldUseBaseQuery =
categoryId && categoryId &&
!isSubCategory && !isSubCategory &&
!searchQuery && !searchQuery &&
!selectedFilterCategory && !selectedFilterCategory &&
!selectedFilterBrand &&
!brandId && !brandId &&
!collectionId; !collectionId &&
!channelId;
const { const { data: baseQueryData, isFetching: baseQueryFetching } =
data: paginatedCategoryProducts, useGetCategoryProductsQuery(
isLoading: categoryLoading, {
isFetching: categoryFetching, categoryId,
} = useGetCategoryProductsQuery( page: currentPage,
{ min_price: minPrice || undefined,
categoryId: categoryId, max_price: maxPrice || undefined,
page: currentPage, brands: selectedFilterBrand || undefined,
min_price: minPrice || undefined, sorting: sorting || undefined,
max_price: maxPrice || undefined, },
}, { skip: !shouldUseBaseQuery }
{ );
skip: !shouldUseBaseQuery,
},
);
const [ const [fetchCategoryPaginated] = useLazyGetAllCategoryProductsPaginatedQuery();
const [fetchBrandPaginated] = useLazyGetBrandProductsQuery();
const [fetchCollectionPaginated] = useLazyGetCollectionProductsPaginatedQuery();
const [fetchChannelPaginated] = useLazyGetChannelProductsQuery();
// ✅ Ref'e al — dependency array'den çıkar, stale closure yok
const fetchersRef = useRef({});
fetchersRef.current = {
fetchCategoryPaginated, fetchCategoryPaginated,
{
data: lazyCategoryProducts,
isLoading: lazyCategoryLoading,
isFetching: lazyCategoryFetching,
reset: resetCategoryPaginated,
},
] = useLazyGetAllCategoryProductsPaginatedQuery();
const [
fetchBrandPaginated, fetchBrandPaginated,
{
data: paginatedBrandProducts,
isLoading: brandPaginatedLoading,
isFetching: brandFetching,
reset: resetBrandPaginated,
},
] = useLazyGetBrandProductsQuery();
const [
fetchCollectionPaginated, fetchCollectionPaginated,
{ fetchChannelPaginated,
data: paginatedCollectionProducts, };
isLoading: collectionPaginatedLoading,
isFetching: collectionFetching,
reset: resetCollectionPaginated,
},
] = useLazyGetCollectionProductsPaginatedQuery();
useEffect(() => { useEffect(() => {
setProducts([]); if (!shouldUseBaseQuery || !baseQueryData) return;
setHasMore(true); const data = baseQueryData.data || [];
const hasNextPage = !!baseQueryData.pagination?.next_page_url;
setProducts((prev) => {
if (currentPage === 1) return data;
const existingIds = new Set(prev.map((p) => p.id));
const newItems = data.filter((p) => !existingIds.has(p.id));
return newItems.length > 0 ? [...prev, ...newItems] : prev;
});
setHasMore(hasNextPage);
}, [baseQueryData, currentPage, shouldUseBaseQuery]);
resetCategoryPaginated?.();
resetBrandPaginated?.();
resetCollectionPaginated?.();
lastFetchKeyRef.current = null;
isFetchingRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, [
contextId,
resetCategoryPaginated,
resetBrandPaginated,
resetCollectionPaginated,
]);
useEffect(() => { useEffect(() => {
if (searchQuery) return; if (shouldUseBaseQuery || searchQuery) return;
if (lastFetchKeyRef.current === fetchKey) {
return;
}
if (isFetchingRef.current) { console.log("🔥 LAZY EFFECT TRIGGERED", {
return; shouldUseBaseQuery,
} categoryId,
collectionId,
brandId,
channelId,
isSubCategory,
selectedFilterCategory,
selectedCategory,
});
if (abortControllerRef.current) { const snapshot = {
abortControllerRef.current.abort(); currentPage,
} selectedFilterCategory,
categoryId,
isSubCategory,
brandId,
collectionId,
channelId,
selectedFilterBrand,
minPrice,
maxPrice,
sorting,
};
abortControllerRef.current = new AbortController(); const requestId = ++activeRequestId.current;
isFetchingRef.current = true; setIsFetching(true);
lastFetchKeyRef.current = fetchKey;
const executeFetch = async () => { const run = async () => {
try { try {
if (selectedFilterBrand) { const {
await fetchBrandPaginated({ fetchCategoryPaginated,
id: selectedFilterBrand, fetchBrandPaginated,
...fetchParams, fetchCollectionPaginated,
}); fetchChannelPaginated,
} = fetchersRef.current; // ✅ ref'ten oku
const params = {
page: snapshot.currentPage,
perPage: 12,
brands: snapshot.selectedFilterBrand || undefined,
min_price: snapshot.minPrice || undefined,
max_price: snapshot.maxPrice || undefined,
sorting: snapshot.sorting || undefined,
};
let result = null;
if (snapshot.selectedFilterCategory) {
result = await fetchCategoryPaginated({
category: { id: snapshot.selectedFilterCategory, children: [] },
...params,
}).unwrap();
} else if (snapshot.categoryId && snapshot.isSubCategory) {
result = await fetchCategoryPaginated({
category: { id: parseInt(snapshot.categoryId), children: [] },
...params,
}).unwrap();
} else if (snapshot.brandId) {
result = await fetchBrandPaginated({
id: snapshot.brandId,
...params,
}).unwrap();
} else if (snapshot.collectionId) {
result = await fetchCollectionPaginated({
collectionId: snapshot.collectionId,
...params,
}).unwrap();
} else if (snapshot.channelId) {
result = await fetchChannelPaginated({
channelId: snapshot.channelId,
...params,
}).unwrap();
}
if (requestId !== activeRequestId.current) return;
if (!result) {
setHasMore(false);
return; return;
} }
if (selectedFilterCategory) { const data = result.data || [];
await fetchCategoryPaginated({ const hasNextPage =
category: { result.pagination?.hasMorePages ||
id: selectedFilterCategory, !!result.pagination?.next_page_url ||
children: [], false;
},
...fetchParams,
});
return;
}
if (categoryId && isSubCategory) { setProducts((prev) => {
await fetchCategoryPaginated({ if (snapshot.currentPage === 1) return data;
category: { const existingIds = new Set(prev.map((p) => p.id));
id: parseInt(categoryId), const newItems = data.filter((p) => !existingIds.has(p.id));
children: [], return newItems.length > 0 ? [...prev, ...newItems] : prev;
}, });
...fetchParams,
});
return;
}
if (brandId) { setHasMore(data.length > 0 ? hasNextPage : false);
await fetchBrandPaginated({ } catch (err) {
id: brandId, if (requestId !== activeRequestId.current) return;
...fetchParams, console.error("Fetch error:", err);
}); setHasMore(false);
return;
}
if (collectionId) {
await fetchCollectionPaginated({
collectionId,
...fetchParams,
});
return;
}
} catch (error) {
if (error.name !== "AbortError") {
console.error("Fetch error:", error);
}
} finally { } finally {
isFetchingRef.current = false; if (requestId === activeRequestId.current) {
setIsFetching(false);
}
} }
}; };
executeFetch(); run();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [ }, [
fetchKey, shouldUseBaseQuery,
searchQuery, searchQuery,
selectedFilterBrand, currentPage,
selectedFilterCategory, selectedFilterCategory,
categoryId, categoryId,
isSubCategory, isSubCategory,
brandId, brandId,
collectionId, collectionId,
fetchParams, channelId,
fetchCategoryPaginated, selectedFilterBrand,
fetchBrandPaginated, minPrice,
fetchCollectionPaginated, maxPrice,
sorting,
// ✅ fetcher fonksiyonlar dependency'den tamamen çıktı
]); ]);
useEffect(() => { const isLoading = shouldUseBaseQuery ? baseQueryFetching : isFetching;
const updateProducts = (newData, hasNextPage) => {
if (!newData || newData.length === 0) {
if (currentPage === 1) {
setProducts([]);
setHasMore(false);
}
return;
}
setProducts((prev) => {
if (currentPage === 1) {
return newData;
}
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = newData.filter((p) => !existingIds.has(p.id));
return newProducts.length > 0 ? [...prev, ...newProducts] : prev;
});
setHasMore(hasNextPage);
};
if (paginatedCategoryProducts && shouldUseBaseQuery) {
updateProducts(
paginatedCategoryProducts.data || [],
!!paginatedCategoryProducts.pagination?.next_page_url,
);
return;
}
if (lazyCategoryProducts) {
updateProducts(
lazyCategoryProducts.data || [],
lazyCategoryProducts.pagination?.hasMorePages || false,
);
return;
}
// Brand products
if (paginatedBrandProducts) {
updateProducts(
paginatedBrandProducts.data || [],
!!paginatedBrandProducts.pagination?.next_page_url,
);
return;
}
if (paginatedCollectionProducts) {
updateProducts(
paginatedCollectionProducts.data || [],
!!paginatedCollectionProducts.pagination?.next_page_url,
);
}
}, [
paginatedCategoryProducts,
lazyCategoryProducts,
paginatedBrandProducts,
paginatedCollectionProducts,
currentPage,
shouldUseBaseQuery,
]);
const isLoading =
categoryLoading ||
lazyCategoryLoading ||
brandPaginatedLoading ||
collectionPaginatedLoading ||
categoryFetching ||
lazyCategoryFetching ||
brandFetching ||
collectionFetching;
return { return {
products, products,
@@ -318,3 +222,4 @@ const useCategoryProducts = ({
}; };
export default useCategoryProducts; export default useCategoryProducts;

View File

@@ -1,5 +1,3 @@
"use client";
import { useEffect, useState, useMemo, useRef } from "react"; import { useEffect, useState, useMemo, useRef } from "react";
import { useParams, useLocation, useNavigate } from "react-router-dom"; import { useParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,26 +13,56 @@ import CategoryFilters from "./components/CategoryFilters";
import CategoryBreadcrumbs from "./components/CategoryBreadcrumbs"; import CategoryBreadcrumbs from "./components/CategoryBreadcrumbs";
import useCategoryData from "./hooks/useCategoryData"; import useCategoryData from "./hooks/useCategoryData";
import useCategoryProducts from "./hooks/useCategoryProducts"; import useCategoryProducts from "./hooks/useCategoryProducts";
import Carconfigurator from "../../components/CarConfigurator/Carconfigurator";
import MobilePhoneCard from "./components/Mobilephonecard"; import MobilePhoneCard from "./components/Mobilephonecard";
const CategoryPage = () => { const CategoryPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { categoryId, collectionId, brandId } = useParams(); const { categoryId, collectionId, brandId, channelId } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [pageState, setPageState] = useState({ const routeKey = useMemo(
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}-${channelId || "x"}`,
[categoryId, collectionId, brandId, channelId],
);
const getSavedState = (key, defaultVal) => {
if (location.state?.clearFilters) {
return defaultVal;
}
try {
const saved = sessionStorage.getItem(`category_${key}_${routeKey}`);
if (saved) return JSON.parse(saved);
} catch (e) {
console.error(e);
}
return defaultVal;
};
const getSavedStateByKey = (route, key) => {
try {
const saved = sessionStorage.getItem(`category_${key}_${route}`);
if (saved) return JSON.parse(saved);
} catch (e) {
console.error(e);
}
return null;
};
const [pageState, setPageState] = useState(() => getSavedState("pageState", {
currentPage: 1, currentPage: 1,
minPrice: "", minPrice: "",
maxPrice: "", maxPrice: "",
}); sorting: "",
}));
const [filterState, setFilterState] = useState({ const [filterState, setFilterState] = useState(() => getSavedState("filterState", {
selectedFilterCategory: null, selectedFilterCategory: null,
selectedFilterBrand: null, selectedFilterBrand: null,
brandSearchQuery: "", brandSearchQuery: "",
}); }));
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [windowWidth, setWindowWidth] = useState(window.innerWidth);
@@ -45,17 +73,12 @@ const CategoryPage = () => {
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, []); }, []);
const routeKey = useMemo(
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}`,
[categoryId, collectionId, brandId]
);
const prevRouteRef = useRef(routeKey); const prevRouteRef = useRef(routeKey);
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const searchResults = useMemo( const searchResults = useMemo(
() => location.state?.searchData?.data || [], () => location.state?.searchData?.data || [],
[location.state?.searchData?.data] [location.state?.searchData?.data],
); );
const searchQuery = location.state?.searchQuery || null; const searchQuery = location.state?.searchQuery || null;
@@ -65,6 +88,7 @@ const CategoryPage = () => {
isSubCategory, isSubCategory,
filtersData, filtersData,
collectionData, collectionData,
channelData,
isLoading: dataLoading, isLoading: dataLoading,
hasError: dataError, hasError: dataError,
fetchFilters, fetchFilters,
@@ -72,6 +96,7 @@ const CategoryPage = () => {
categoryId, categoryId,
collectionId, collectionId,
brandId, brandId,
channelId,
selectedFilterCategory: filterState.selectedFilterCategory, selectedFilterCategory: filterState.selectedFilterCategory,
searchQuery, searchQuery,
}); });
@@ -85,6 +110,7 @@ const CategoryPage = () => {
} = useCategoryProducts({ } = useCategoryProducts({
categoryId, categoryId,
collectionId, collectionId,
channelId,
brandId, brandId,
selectedCategory, selectedCategory,
isSubCategory, isSubCategory,
@@ -93,7 +119,10 @@ const CategoryPage = () => {
selectedFilterBrand: filterState.selectedFilterBrand, selectedFilterBrand: filterState.selectedFilterBrand,
minPrice: pageState.minPrice, minPrice: pageState.minPrice,
maxPrice: pageState.maxPrice, maxPrice: pageState.maxPrice,
sorting: pageState.sorting,
searchQuery, searchQuery,
initialProducts: getSavedState("products", []),
initialHasMore: getSavedState("hasMore", true),
}); });
const isMobilePhoneView = const isMobilePhoneView =
(Number(categoryId) === 531 || (Number(categoryId) === 531 ||
@@ -103,21 +132,51 @@ const CategoryPage = () => {
if (isInitialMount.current) { if (isInitialMount.current) {
isInitialMount.current = false; isInitialMount.current = false;
prevRouteRef.current = routeKey; prevRouteRef.current = routeKey;
const savedScroll = getSavedState("scroll", 0);
if (savedScroll > 0) {
setTimeout(() => window.scrollTo(0, savedScroll), 100);
}
return; return;
} }
if (prevRouteRef.current === routeKey) return; if (prevRouteRef.current === routeKey && !location.state?.clearFilters) return;
prevRouteRef.current = routeKey; prevRouteRef.current = routeKey;
setAllProducts([]); const shouldClear = location.state?.clearFilters;
setHasMore(true);
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); const savedPageState = shouldClear ? null : getSavedStateByKey(routeKey, "pageState");
setFilterState({ const savedFilterState = shouldClear ? null : getSavedStateByKey(routeKey, "filterState");
selectedFilterCategory: null, const savedProducts = shouldClear ? null : getSavedStateByKey(routeKey, "products");
selectedFilterBrand: null, const savedHasMore = shouldClear ? null : getSavedStateByKey(routeKey, "hasMore");
brandSearchQuery: "",
}); if (savedPageState && savedFilterState && savedProducts) {
setPageState(savedPageState);
setFilterState(savedFilterState);
setAllProducts(savedProducts);
setHasMore(savedHasMore ?? true);
const savedScroll = getSavedStateByKey(routeKey, "scroll");
if (savedScroll !== null) {
setTimeout(() => window.scrollTo(0, savedScroll), 100);
}
} else {
if (prevRouteRef.current !== routeKey) {
setAllProducts([]);
setHasMore(true);
}
setPageState({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: "",
});
setFilterState({
selectedFilterCategory: null,
selectedFilterBrand: null,
brandSearchQuery: "",
});
window.scrollTo(0, 0);
}
if (location.state?.clearFilters) { if (location.state?.clearFilters) {
navigate(location.pathname, { replace: true, state: {} }); navigate(location.pathname, { replace: true, state: {} });
@@ -125,11 +184,52 @@ const CategoryPage = () => {
}, [ }, [
routeKey, routeKey,
location.state?.clearFilters, location.state?.clearFilters,
location.pathname,
navigate, navigate,
setAllProducts, setAllProducts,
setHasMore, setHasMore,
]); ]);
const stateRef = useRef();
useEffect(() => {
stateRef.current = { routeKey, pageState, filterState, allProducts, hasMore };
}, [routeKey, pageState, filterState, allProducts, hasMore]);
useEffect(() => {
if (stateRef.current) {
try {
const { routeKey: key, pageState: ps, filterState: fs, allProducts: ap, hasMore: hm } = stateRef.current;
sessionStorage.setItem(`category_pageState_${key}`, JSON.stringify(ps));
sessionStorage.setItem(`category_filterState_${key}`, JSON.stringify(fs));
sessionStorage.setItem(`category_products_${key}`, JSON.stringify(ap));
sessionStorage.setItem(`category_hasMore_${key}`, JSON.stringify(hm));
} catch (error) {
console.warn("Could not save category state to sessionStorage", error);
}
}
}, [pageState, filterState, allProducts, hasMore, routeKey]);
useEffect(() => {
let scrollTimeout;
const handleScroll = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
if (stateRef.current) {
try {
sessionStorage.setItem(`category_scroll_${stateRef.current.routeKey}`, JSON.stringify(window.scrollY));
} catch (e) {
// ignore
}
}
}, 100);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
if (scrollTimeout) clearTimeout(scrollTimeout);
};
}, []);
const filteredProducts = useMemo(() => { const filteredProducts = useMemo(() => {
let list = searchQuery ? searchResults : allProducts; let list = searchQuery ? searchResults : allProducts;
@@ -160,7 +260,7 @@ const CategoryPage = () => {
if (filterState.selectedFilterCategory) { if (filterState.selectedFilterCategory) {
const cat = findCategoryById( const cat = findCategoryById(
categoriesData?.data, categoriesData?.data,
filterState.selectedFilterCategory filterState.selectedFilterCategory,
); );
return cat?.name || "Category"; return cat?.name || "Category";
} }
@@ -185,7 +285,12 @@ const CategoryPage = () => {
selectedFilterBrand: null, selectedFilterBrand: null,
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); setPageState((prev) => ({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: prev.sorting,
}));
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
@@ -193,15 +298,15 @@ const CategoryPage = () => {
}; };
const handleFilterCategoryDeselect = () => { const handleFilterCategoryDeselect = () => {
setFilterState((prev) => ({ setFilterState((prev) => ({ ...prev, selectedFilterCategory: null }));
setPageState((prev) => ({
...prev, ...prev,
selectedFilterCategory: null, currentPage: 1,
minPrice: "",
maxPrice: "",
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
if (categoryId) fetchFilters({ category_id: categoryId }); if (categoryId) fetchFilters({ category_id: categoryId });
}; };
@@ -211,32 +316,29 @@ const CategoryPage = () => {
selectedFilterBrand: brandId, selectedFilterBrand: brandId,
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); setPageState((prev) => ({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: prev.sorting,
}));
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
}; };
const handleFilterBrandDeselect = () => { const handleFilterBrandDeselect = () => {
setFilterState((prev) => ({ setFilterState((prev) => ({ ...prev, selectedFilterBrand: null }));
setPageState((prev) => ({
...prev, ...prev,
selectedFilterBrand: null, currentPage: 1,
minPrice: "",
maxPrice: "",
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
}; };
const handleCategoryClick = (targetId) => { const handleCategoryClick = (targetId) => {
setFilterState({
selectedFilterCategory: null,
selectedFilterBrand: null,
brandSearchQuery: "",
});
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]);
setHasMore(true);
navigate(`/category/${targetId}`, { navigate(`/category/${targetId}`, {
replace: false, replace: false,
state: { clearFilters: true, timestamp: Date.now() }, state: { clearFilters: true, timestamp: Date.now() },
@@ -272,18 +374,35 @@ const CategoryPage = () => {
return ( return (
<div className={styles.categoryPage}> <div className={styles.categoryPage}>
{(categoryId || filterState.selectedFilterCategory) && ( {channelId && channelData ? (
<CategoryBreadcrumbs <div className={styles.channelHeader}>
categoriesData={categoriesData} {channelData.media?.[0]?.thumbnail && (
categoryId={filterState.selectedFilterCategory || categoryId} <img
onCategoryClick={handleCategoryClick} src={channelData.media[0].thumbnail}
/> alt={channelData.name}
)} className={styles.channelLogo}
/>
)}
<div className={styles.channelInfo}>
<h1>{channelData.name}</h1>
</div>
</div>
) : (
<>
{(categoryId || filterState.selectedFilterCategory) && (
<CategoryBreadcrumbs
categoriesData={categoriesData}
categoryId={filterState.selectedFilterCategory || categoryId}
onCategoryClick={handleCategoryClick}
/>
)}
<h2>{pageTitle}</h2> <h2>{pageTitle}</h2>
<p className={styles.sum}> <p className={styles.sum}>
{t("category.total")}: {totalItems} {t("category.items")} {t("category.total")}: {totalItems} {t("category.items")}
</p> </p>
</>
)}
<div className={styles.bars}> <div className={styles.bars}>
<button <button
@@ -310,22 +429,22 @@ const CategoryPage = () => {
minPrice={pageState.minPrice} minPrice={pageState.minPrice}
maxPrice={pageState.maxPrice} maxPrice={pageState.maxPrice}
onMinPriceChange={(value) => { onMinPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({ setPageState((prev) => ({
...prev, ...prev,
minPrice: value, minPrice: value,
currentPage: 1, currentPage: 1,
})); }));
setAllProducts([]);
setHasMore(true);
}} }}
onMaxPriceChange={(value) => { onMaxPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({ setPageState((prev) => ({
...prev, ...prev,
maxPrice: value, maxPrice: value,
currentPage: 1, currentPage: 1,
})); }));
setAllProducts([]);
setHasMore(true);
}} }}
onCategorySelect={handleFilterCategorySelect} onCategorySelect={handleFilterCategorySelect}
onCategoryDeselect={handleFilterCategoryDeselect} onCategoryDeselect={handleFilterCategoryDeselect}
@@ -334,6 +453,15 @@ const CategoryPage = () => {
onBrandSearchChange={(query) => onBrandSearchChange={(query) =>
setFilterState((prev) => ({ ...prev, brandSearchQuery: query })) setFilterState((prev) => ({ ...prev, brandSearchQuery: query }))
} }
sorting={pageState.sorting}
onSortingChange={(value) => {
setPageState((prev) => {
const newSorting = prev.sorting === value ? "" : value;
setAllProducts([]); // her zaman sıfırla
setHasMore(true);
return { ...prev, sorting: newSorting, currentPage: 1 };
});
}}
/> />
</Drawer> </Drawer>
@@ -348,22 +476,22 @@ const CategoryPage = () => {
minPrice={pageState.minPrice} minPrice={pageState.minPrice}
maxPrice={pageState.maxPrice} maxPrice={pageState.maxPrice}
onMinPriceChange={(value) => { onMinPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({ setPageState((prev) => ({
...prev, ...prev,
minPrice: value, minPrice: value,
currentPage: 1, currentPage: 1,
})); }));
setAllProducts([]);
setHasMore(true);
}} }}
onMaxPriceChange={(value) => { onMaxPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({ setPageState((prev) => ({
...prev, ...prev,
maxPrice: value, maxPrice: value,
currentPage: 1, currentPage: 1,
})); }));
setAllProducts([]);
setHasMore(true);
}} }}
onCategorySelect={handleFilterCategorySelect} onCategorySelect={handleFilterCategorySelect}
onCategoryDeselect={handleFilterCategoryDeselect} onCategoryDeselect={handleFilterCategoryDeselect}
@@ -372,9 +500,22 @@ const CategoryPage = () => {
onBrandSearchChange={(query) => onBrandSearchChange={(query) =>
setFilterState((prev) => ({ ...prev, brandSearchQuery: query })) setFilterState((prev) => ({ ...prev, brandSearchQuery: query }))
} }
sorting={pageState.sorting}
onSortingChange={(value) => {
setPageState((prev) => {
const newSorting = prev.sorting === value ? "" : value;
setAllProducts([]); // her zaman sıfırla
setHasMore(true);
return { ...prev, sorting: newSorting, currentPage: 1 };
});
}}
/> />
<main className={styles.productsContainer}> <main className={styles.productsContainer}>
{(Number(categoryId) === 1136 ||
Number(filterState.selectedFilterCategory) === 1136) && (
<Carconfigurator />
)}
{isInitialLoad ? ( {isInitialLoad ? (
<div className={styles.loaderContainer}> <div className={styles.loaderContainer}>
<Loader /> <Loader />
@@ -385,12 +526,14 @@ const CategoryPage = () => {
next={loadMoreData} next={loadMoreData}
hasMore={hasMore} hasMore={hasMore}
scrollThreshold={0.8} scrollThreshold={0.8}
scrollableTarget={null}
style={{ overflow: "hidden" }}
loader={ loader={
<div className={styles.loaderContainer}> <div className={`${styles.loaderContainer} `}>
<Loader /> <Loader />
</div> </div>
} }
className={`${styles.productGrid} ${ className={`${styles.productGrid} ${
isMobilePhoneView ? styles.mobilePhoneGrid : "" isMobilePhoneView ? styles.mobilePhoneGrid : ""
}`} }`}
> >
@@ -409,7 +552,7 @@ const CategoryPage = () => {
showFavoriteButton showFavoriteButton
showAddToCart showAddToCart
/> />
) ),
)} )}
</InfiniteScroll> </InfiniteScroll>
) : ( ) : (

View File

@@ -11,12 +11,12 @@ const DeliveryTerms = () => {
<p>Eltip bermek hyzmaty Aşgabat şäheriniň çägi bilen bir hatarda Büzmeýine we Änew şäherine hem elýeterlidir;</p> <p>Eltip bermek hyzmaty Aşgabat şäheriniň çägi bilen bir hatarda Büzmeýine we Änew şäherine hem elýeterlidir;</p>
</div> </div>
<div className={styles.termItem}> {/* <div className={styles.termItem}>
<p> <p>
Sargydyň iň pes çägi <span className={styles.highlight}>50 manat</span> bolmaly; Sargydyň iň pes çägi <span className={styles.highlight}>50 manat</span> bolmaly;
sargydyňyz <span className={styles.highlight}>150 manatdan</span> geçse eltip bermek hyzmaty mugt; sargydyňyz <span className={styles.highlight}>150 manatdan</span> geçse eltip bermek hyzmaty mugt;
</p> </p>
</div> </div> */}
<div className={styles.termItem}> <div className={styles.termItem}>
<p>Saýtdan sargyt edeniňizden soňra operator size jaň edip sargyt tassyklar (eger hemişelik müşderi bolsaňyz sargytlaryňyz islegiňize göra awtomatik usulda hem tassyklanýar);</p> <p>Saýtdan sargyt edeniňizden soňra operator size jaň edip sargyt tassyklar (eger hemişelik müşderi bolsaňyz sargytlaryňyz islegiňize göra awtomatik usulda hem tassyklanýar);</p>

View File

@@ -127,6 +127,7 @@
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
flex-direction: column; flex-direction: column;
} }
@@ -140,6 +141,7 @@
.row { .row {
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 10px;
gap: 12px;
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
justify-content: space-between; justify-content: space-between;
} }
@@ -157,10 +159,10 @@
&:first-child { &:first-child {
font-weight: 600; font-weight: 600;
color: #000; color: #000;
width: 25%; // width: 25%;
@media screen and (max-width: 640px) { // @media screen and (max-width: 640px) {
width: 50%; // width: 50%;
} // }
} }
&:last-child { &:last-child {
@@ -295,3 +297,106 @@
} }
} }
} }
.pendingPriceBadgeWrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.pendingPriceBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #faeeda;
border: 0.5px solid #ef9f27;
color: #854f0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.pendingPriceTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-background-primary, #ffffff);
border: 0.5px solid var(--color-border-secondary, #e2e2e2);
border-radius: var(--border-radius-md, 6px);
padding: 8px 12px;
width: 220px;
font-size: 13px;
color: var(--color-text-primary, #333333);
line-height: 1.5;
z-index: 100;
white-space: normal;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@media (max-width: 767px) {
display: none;
}
strong {
display: block;
margin-bottom: 4px;
color: var(--color-text-primary, #000000);
}
}
:global {
.pending-price-modal {
.ant-modal-content {
border-radius: 12px;
padding: 24px;
@media (max-width: 767px) {
padding: 20px;
}
}
.ant-modal-header {
margin-bottom: 12px;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
@media (max-width: 767px) {
font-size: 16px;
}
}
}
.ant-modal-body {
p {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0;
@media (max-width: 767px) {
font-size: 13px;
}
}
}
.ant-modal-footer {
margin-top: 20px;
.ant-btn-primary {
background-color: #888888;
border-color: #888888;
border-radius: 6px;
height: 36px;
padding: 0 20px;
font-weight: 500;
&:hover {
background-color: #666666;
border-color: #666666;
}
}
}
}
}

View File

@@ -1,63 +1,63 @@
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import styles from "./OrderDetail.module.scss"; import styles from "./OrderDetail.module.scss";
import { Ban, CircleCheck, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetOrderByIdQuery } from "../../app/api/orderApi"; // Update with your correct path import { useGetOrderByIdQuery } from "../../app/api/orderApi";
import track from "../../assets/track.jpg"; // Keep for delivery service icon import track from "../../assets/track.jpg";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import { Result, Button } from "antd"; import { Result, Button } from "antd";
import { useNavigate } from "react-router-dom"; import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const OrderDetail = () => { const OrderDetail = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams(); // Get the order ID from URL params const { id } = useParams();
const { data: orderData, isLoading, error } = useGetOrderByIdQuery(id);
const navigate = useNavigate(); const navigate = useNavigate();
// Format date function const { data: orderData, isLoading, error } = useGetOrderByIdQuery(id);
const formatDate = (dateString) => { const formatDate = (dateString) => {
try { try {
const date = new Date(dateString); return new Date(dateString).toLocaleString("tk-TM", {
return date.toLocaleString("tk-TM", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
} catch (e) { } catch {
return dateString; return dateString;
} }
}; };
// Format delivery time for display
const formatDeliveryTime = (time, date) => { const formatDeliveryTime = (time, date) => {
try { try {
const deliveryDate = new Date(date); const formatted = new Date(date).toLocaleDateString("tk-TM", {
const formattedDate = deliveryDate.toLocaleDateString("tk-TM", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
}); });
return `${time} (${formattedDate})`; return `${time} (${formatted})`;
} catch (e) { } catch {
return `${time}`; return time;
} }
}; };
// Calculate total order amount
const calculateTotal = (orderItems) => { const calculateTotal = (orderItems) => {
if (!orderItems || !orderItems.length) return 0; if (!orderItems?.length) return null;
const hasZero = orderItems.some((item) =>
isPriceZero(item.unit_price_amount),
);
if (hasZero) return null;
return orderItems return orderItems
.reduce( .reduce(
(sum, item) => sum + parseFloat(item.unit_price_amount) * item.quantity, (sum, item) => sum + parseFloat(item.unit_price_amount) * item.quantity,
0 0,
) )
.toFixed(2); .toFixed(2);
}; };
// Handle loading state
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
// Handle error state
if (error) if (error)
return ( return (
<Result <Result
@@ -72,10 +72,8 @@ const OrderDetail = () => {
/> />
); );
// Handle case where order data is not available
if (!orderData) return <div className={styles.notFound}>Order not found</div>; if (!orderData) return <div className={styles.notFound}>Order not found</div>;
// Calculate total
const totalAmount = calculateTotal(orderData.orderItems); const totalAmount = calculateTotal(orderData.orderItems);
return ( return (
@@ -84,39 +82,10 @@ const OrderDetail = () => {
<h1> <h1>
{t("order.orderNumber")}: {orderData.id} {t("order.orderNumber")}: {orderData.id}
</h1> </h1>
<div className={styles.Buttons}> <div className={styles.Buttons} />
{/* <button className={styles.repeatButton}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
>
<path d="M480 256c-17.67 0-32 14.31-32 32c0 52.94-43.06 96-96 96H192L192 344c0-9.469-5.578-18.06-14.23-21.94C169.1 318.3 159 319.8 151.9 326.2l-80 72C66.89 402.7 64 409.2 64 416s2.891 13.28 7.938 17.84l80 72C156.4 509.9 162.2 512 168 512c3.312 0 6.615-.6875 9.756-2.062C186.4 506.1 192 497.5 192 488L192 448h160c88.22 0 160-71.78 160-160C512 270.3 497.7 256 480 256zM160 128h159.1L320 168c0 9.469 5.578 18.06 14.23 21.94C337.4 191.3 340.7 192 343.1 192c5.812 0 11.57-2.125 16.07-6.156l80-72C445.1 109.3 448 102.8 448 95.1s-2.891-13.28-7.938-17.84l-80-72c-7.047-6.312-17.19-7.875-25.83-4.094C325.6 5.938 319.1 14.53 319.1 24L320 64H160C71.78 64 0 135.8 0 224c0 17.69 14.33 32 32 32s32-14.31 32-32C64 171.1 107.1 128 160 128z"></path>
</svg>{" "}
{t("order.repeatOrder")}
</button> */}
{/* <button className={styles.cancelButton}>
{" "}
<Ban />
{t("order.dropOrder")}
</button> */}
</div>
</div> </div>
<div className={styles.content}>
{/* Order Status */}
{/* <div className={styles.status}>
<p className={styles.statusText}>
<span className={styles.statusIcon}>
<CircleCheck />
</span>{" "}
{t("order.Your_order_has_been_accepted")}
</p>
<span className={styles.close}>
<X />
</span>
</div> */}
{/* Order Details */} <div className={styles.content}>
<div className={styles.details}> <div className={styles.details}>
<div className={styles.rowContainer}> <div className={styles.rowContainer}>
<div className={styles.row}> <div className={styles.row}>
@@ -132,7 +101,7 @@ const OrderDetail = () => {
<span> <span>
{formatDeliveryTime( {formatDeliveryTime(
orderData.delivery_time, orderData.delivery_time,
orderData.delivery_at orderData.delivery_at,
)} )}
</span> </span>
</div> </div>
@@ -144,11 +113,26 @@ const OrderDetail = () => {
</div> </div>
<div className={styles.row}> <div className={styles.row}>
<span>{t("order.sum")}:</span> <span>{t("order.sum")}:</span>
<span className={styles.total}>{totalAmount} m.</span> <span className={styles.total}>
{totalAmount === null ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<PendingPriceBadge t={t} />
</span>
) : (
`${totalAmount} m.`
)}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Desktop table */}
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
@@ -165,9 +149,12 @@ const OrderDetail = () => {
<tbody> <tbody>
{orderData.orderItems.map((item, index) => { {orderData.orderItems.map((item, index) => {
const product = item.product; const product = item.product;
const itemTotal = ( const zeroPriceItem = isPriceZero(item.unit_price_amount);
parseFloat(item.unit_price_amount) * item.quantity const itemTotal = zeroPriceItem
).toFixed(2); ? null
: (
parseFloat(item.unit_price_amount) * item.quantity
).toFixed(2);
return ( return (
<tr key={index}> <tr key={index}>
@@ -181,27 +168,50 @@ const OrderDetail = () => {
<td>{product.name}</td> <td>{product.name}</td>
<td>{product.brand || "-"}</td> <td>{product.brand || "-"}</td>
<td>{product.id || "-"}</td> <td>{product.id || "-"}</td>
<td>{item.unit_price_amount} m.</td> <td>
{zeroPriceItem ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<PendingPriceBadge />
</span>
) : (
`${item.unit_price_amount} m.`
)}
</td>
<td>{item.quantity}</td> <td>{item.quantity}</td>
<td>{itemTotal} m.</td> <td>
{itemTotal === null ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<PendingPriceBadge />
</span>
) : (
`${itemTotal} m.`
)}
</td>
</tr> </tr>
); );
})} })}
{/* Add delivery service row if shipping method exists */}
{orderData.shipping_method && ( {orderData.shipping_method && (
<tr> <tr>
<td> <td>
<img <img src={track} alt="Delivery" className={styles.image} />
src={track}
alt="Delivery Service"
className={styles.image}
/>
</td> </td>
<td>Eltip bermek hyzmaty</td> <td>Eltip bermek hyzmaty</td>
<td>Beýleki</td> <td>Beýleki</td>
<td>DELIVERY</td> <td>DELIVERY</td>
<td>10.00 m.</td>{" "} <td>10.00 m.</td>
{/* You may need to get actual delivery cost from API */}
<td>1</td> <td>1</td>
<td>10.00 m.</td> <td>10.00 m.</td>
</tr> </tr>
@@ -210,13 +220,15 @@ const OrderDetail = () => {
</table> </table>
</div> </div>
</div> </div>
{/* Mobile View */}
{/* Mobile cards */}
<div className={styles.productList}> <div className={styles.productList}>
{orderData.orderItems.map((item, index) => { {orderData.orderItems.map((item, index) => {
const product = item.product; const product = item.product;
const itemTotal = ( const zeroPriceItem = isPriceZero(item.unit_price_amount);
parseFloat(item.unit_price_amount) * item.quantity const itemTotal = zeroPriceItem
).toFixed(2); ? null
: (parseFloat(item.unit_price_amount) * item.quantity).toFixed(2);
return ( return (
<div className={styles.card} key={index}> <div className={styles.card} key={index}>
@@ -233,18 +245,30 @@ const OrderDetail = () => {
{t("order.quantity")}: {item.quantity} {t("order.quantity")}: {item.quantity}
</span> </span>
<span className={styles.price}> <span className={styles.price}>
{item.unit_price_amount} m. {zeroPriceItem ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<PendingPriceBadge />
</span>
) : (
`${item.unit_price_amount} m.`
)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{/* Add delivery service card if shipping method exists */}
{orderData.shipping_method && ( {/* {orderData.shipping_method && (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.imageContainer}> <div className={styles.imageContainer}>
<img src={track} alt="Delivery Service" /> <img src={track} alt="Delivery" />
</div> </div>
<div className={styles.detailsMobile}> <div className={styles.detailsMobile}>
<h3 className={styles.title}>Beýleki</h3> <h3 className={styles.title}>Beýleki</h3>
@@ -257,7 +281,7 @@ const OrderDetail = () => {
</div> </div>
</div> </div>
</div> </div>
)} )} */}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,10 @@
// SargytlarymComponent.module.scss // SargytlarymComponent.module.scss
.container { .container {
padding: 15px 24px 0 24px; padding: 15px 24px 24px 24px;
max-width: 1366px; max-width: 1366px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
a{ a {
text-decoration: none; text-decoration: none;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -121,3 +121,106 @@
font-weight: 700; font-weight: 700;
color: #888888; color: #888888;
} }
.pendingPriceBadgeWrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.pendingPriceBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #faeeda;
border: 0.5px solid #ef9f27;
color: #854f0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.pendingPriceTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-background-primary, #ffffff);
border: 0.5px solid var(--color-border-secondary, #e2e2e2);
border-radius: var(--border-radius-md, 6px);
padding: 8px 12px;
width: 220px;
font-size: 13px;
color: var(--color-text-primary, #333333);
line-height: 1.5;
z-index: 100;
white-space: normal;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@media (max-width: 767px) {
display: none;
}
strong {
display: block;
margin-bottom: 4px;
color: var(--color-text-primary, #000000);
}
}
:global {
.pending-price-modal {
.ant-modal-content {
border-radius: 12px;
padding: 24px;
@media (max-width: 767px) {
padding: 20px;
}
}
.ant-modal-header {
margin-bottom: 12px;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
@media (max-width: 767px) {
font-size: 16px;
}
}
}
.ant-modal-body {
p {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0;
@media (max-width: 767px) {
font-size: 13px;
}
}
}
.ant-modal-footer {
margin-top: 20px;
.ant-btn-primary {
background-color: #888888;
border-color: #888888;
border-radius: 6px;
height: 36px;
padding: 0 20px;
font-weight: 500;
&:hover {
background-color: #666666;
border-color: #666666;
}
}
}
}
}

View File

@@ -1,36 +1,40 @@
// Orders.jsx import React, { useState } from "react";
import React from "react";
import styles from "./Orders.module.scss"; import styles from "./Orders.module.scss";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetOrdersQuery } from "../../app/api/orderApi"; // Update with your correct path import { useGetOrdersQuery } from "../../app/api/orderApi";
import EmptyOrderState from "./emptyOrder"; // Import the EmptyOrderState component import EmptyOrderState from "./emptyOrder";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import { Result, Button } from "antd"; import { Result, Button } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const orderHasZeroPrice = (orderItems) =>
orderItems?.some((item) => isPriceZero(item.unit_price_amount));
const Orders = () => { const Orders = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: orders, isLoading, error } = useGetOrdersQuery(); const { data: orders, isLoading, error } = useGetOrdersQuery();
const navigate = useNavigate(); const navigate = useNavigate();
// Function to format date - implement this or use a library like date-fns
const formatOrderDate = (dateString) => { const formatOrderDate = (dateString) => {
try { try {
const date = new Date(dateString); return new Date(dateString).toLocaleString("tk-TM", {
return date.toLocaleString("tk-TM", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
} catch (e) { } catch {
return dateString; return dateString;
} }
}; };
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
// Handle error state
if (error) if (error)
return ( return (
<Result <Result
@@ -45,16 +49,13 @@ const Orders = () => {
/> />
); );
// Handle empty orders - render EmptyOrderState component if (!orders || orders.length === 0) return <EmptyOrderState />;
if (!orders || orders.length === 0) {
return <EmptyOrderState />;
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h2 className={styles.title}>Sargytlarym</h2> <h2 className={styles.title}>Sargytlarym</h2>
{/* Desktop table view */} {/* Desktop table */}
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
@@ -69,11 +70,11 @@ const Orders = () => {
</thead> </thead>
<tbody> <tbody>
{orders.map((order) => { {orders.map((order) => {
// Calculate total order amount const hasZeroPrice = orderHasZeroPrice(order.orderItems);
const totalAmount = order.orderItems.reduce( const totalAmount = order.orderItems.reduce(
(sum, item) => (sum, item) =>
sum + parseFloat(item.unit_price_amount) * item.quantity, sum + parseFloat(item.unit_price_amount) * item.quantity,
0 0,
); );
return ( return (
@@ -81,7 +82,19 @@ const Orders = () => {
<td>{order.id}</td> <td>{order.id}</td>
<td>{formatOrderDate(order.delivery_at)}</td> <td>{formatOrderDate(order.delivery_at)}</td>
<td style={{ color: "#888888", fontWeight: "700" }}> <td style={{ color: "#888888", fontWeight: "700" }}>
{totalAmount.toFixed(2)} m. {hasZeroPrice ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
</span>
) : (
`${totalAmount.toFixed(2)} m.`
)}
</td> </td>
<td>{order.payment_type}</td> <td>{order.payment_type}</td>
<td>{order.status}</td> <td>{order.status}</td>
@@ -99,50 +112,72 @@ const Orders = () => {
</table> </table>
</div> </div>
{/* Mobile card view */} {/* Mobile cards */}
<div className={styles.Mobilecontainer}> <div className={styles.Mobilecontainer}>
{orders.map((order) => { {orders.map((order) => {
const hasZeroPrice = orderHasZeroPrice(order.orderItems);
const totalAmount = order.orderItems.reduce( const totalAmount = order.orderItems.reduce(
(sum, item) => (sum, item) =>
sum + parseFloat(item.unit_price_amount) * item.quantity, sum + parseFloat(item.unit_price_amount) * item.quantity,
0 0,
); );
return ( return (
<Link to={`/orderdetail/${order.id}`} key={order.id}> <div
<div className={styles.orderCard}> key={order.id}
<div className={styles.orderRow}> className={styles.orderCard}
<span className={styles.label}> onClick={(e) => {
{t("order.orderNumber")}: // Modal veya badge içerisine tıklandığında yönlendirmeyi engelle
</span> if (
<span className={styles.value}>{order.id}</span> e.target.closest(`.${styles.pendingPriceBadgeWrapper}`) ||
</div> e.target.closest(".ant-modal-root") ||
<div className={styles.orderRow}> e.target.closest(".ant-modal-wrap")
<span className={styles.label}>{t("order.orderDate")}:</span> ) {
<span className={styles.value}> return;
{formatOrderDate(order.delivery_at)} }
</span> navigate(`/orderdetail/${order.id}`);
</div> }}
<div className={styles.orderRow}> style={{ cursor: "pointer" }}
<span className={styles.label}>{t("order.sum")}:</span> >
<span className={styles.total}> <div className={styles.orderRow}>
{totalAmount.toFixed(2)} m. <span className={styles.label}>{t("order.orderNumber")}:</span>
</span> <span className={styles.value}>{order.id}</span>
</div>
<div className={styles.orderRow}>
<span className={styles.label}>
{t("checkout.paymentMethod")}:
</span>
<span className={styles.value}>{order.payment_type}</span>
</div>
<div className={styles.orderRow}>
<span className={styles.label}>
{t("order.orderStatus")}:
</span>
<span className={styles.value}>{order.status}</span>
</div>
</div> </div>
</Link> <div className={styles.orderRow}>
<span className={styles.label}>{t("order.orderDate")}:</span>
<span className={styles.value}>
{formatOrderDate(order.delivery_at)}
</span>
</div>
<div className={styles.orderRow}>
<span className={styles.label}>{t("order.sum")}:</span>
<span className={styles.total}>
{hasZeroPrice ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
</span>
) : (
`${totalAmount.toFixed(2)} m.`
)}
</span>
</div>
<div className={styles.orderRow}>
<span className={styles.label}>
{t("checkout.paymentMethod")}:
</span>
<span className={styles.value}>{order.payment_type}</span>
</div>
<div className={styles.orderRow}>
<span className={styles.label}>{t("order.orderStatus")}:</span>
<span className={styles.value}>{order.status}</span>
</div>
</div>
); );
})} })}
</div> </div>

View File

@@ -7,6 +7,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
// ─── Breadcrumb ───────────────────────────────────────────────────
.breadcrumb { .breadcrumb {
margin-bottom: 15px; margin-bottom: 15px;
color: #666; color: #666;
@@ -19,76 +20,274 @@
} }
} }
// ─── Product section: 3 kolon ─────────────────────────────────────
// desktop: [image 35%] | [info+description flex:1] | [purchase 260px]
// tablet: [image 45%] [info 55%] / [purchase full-width]
// mobile: tek kolon
.productSection { .productSection {
display: flex; display: flex;
gap: 24px; gap: 24px;
background-color: rgb(255, 255, 255); align-items: flex-start;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); // background-color: #fff;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: 4px;
padding: 1.25rem; // padding: 1.25rem;
box-sizing: border-box; box-sizing: border-box;
@media screen and (max-width: 900px) {
flex-wrap: wrap;
}
@media screen and (max-width: 639px) { @media screen and (max-width: 639px) {
flex-direction: column; flex-direction: column;
padding: 0.75rem; border-radius: 8px;
// padding: 0.75rem;
} }
} }
// ─── Sol: resim kolonu ────────────────────────────────────────────
.productImage { .productImage {
background: #fff; background: #fff;
padding: 20px; // padding: 20px;
border-radius: 8px; border-radius: 8px;
width: 40%; width: 36%;
flex-shrink: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
padding: 5px; width: 45%;
border-radius: 8px;
// padding: 5px;
} }
@media screen and (max-width: 639px) { @media screen and (max-width: 639px) {
width: 100%; width: 100%;
padding: 0;
border-radius: 8px;
} }
img { img {
width: 99%; width: 99%;
height: auto; height: auto;
object-fit: contain; object-fit: contain;
// border: 1px solid #eee; }
@media screen and (max-width: 900px) { }
height: 100%;
// ─── Orta: isim + meta + description kolonu ───────────────────────
.productInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
@media screen and (max-width: 900px) {
// tablet: image(45%) + info yan yana, purchase wrap ile alta iner
width: calc(55% - 24px);
flex: none;
}
@media screen and (max-width: 639px) {
width: 100%;
// mobile'da purchase card orta kolona taşınır (sticky bar var)
border-bottom: 1px solid #e5e7eb;
}
.productTitle {
font-size: 24px;
font-weight: 600;
margin: 0 0 4px;
color: #000;
line-height: 1.3;
}
}
// ─── Sağ: satın alma kartı kolonu ────────────────────────────────
.purchaseCol {
width: 260px;
flex-shrink: 0;
@media screen and (max-width: 900px) {
width: 100%;
}
@media screen and (max-width: 639px) {
display: none; // mobile'da sticky bar devreye girer
}
}
.purchaseCard {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
// ─── Fiyat satırı ─────────────────────────────────────────────────
.priceRow {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
}
.priceLabel {
font-size: 18px;
font-weight: 500;
color: #666;
}
.priceRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.price {
font-size: 28px;
font-weight: 700;
color: #000;
}
.oldPrice {
font-size: 14px;
color: #d32824;
text-decoration: line-through;
font-weight: 500;
}
// ─── Aksiyon butonları satırı ─────────────────────────────────────
.Btn {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
.addToCartButton {
flex: 1;
height: 42px;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 6px;
border: none;
background-color: #d32824;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease;
white-space: nowrap;
svg {
fill: #fff;
width: 18px;
height: 18px;
flex-shrink: 0;
}
&:hover {
background-color: #e86064;
}
}
.favoriteButton {
width: 42px;
height: 42px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
background-color: #fff;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: border-color 150ms ease;
svg {
fill: #888;
height: 20px;
width: 20px;
}
&:hover {
border-color: #d32824;
svg {
fill: #d32824;
} }
} }
} }
.productInfo { // ─── Quantity controls ────────────────────────────────────────────
width: 60%; .quantityControls {
@media screen and (max-width: 639px) { flex: 1;
width: 100%; height: 42px;
} display: flex;
@media screen and (max-width: 520px) { align-items: center;
border-bottom: 1px solid #e5e7eb; background-color: #d32824;
} border-radius: 6px;
.productTitle { overflow: hidden;
font-size: 30px;
font-weight: 600; span {
margin-bottom: 12px; color: #fff;
color: #000000; font-weight: 700;
font-size: 16px;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
} }
.productDescription { .quantityBtn {
font-size: 14px; width: 42px;
color: #000; height: 42px;
margin-bottom: 24px; border: none;
background: transparent;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
svg {
fill: #fff;
width: 18px;
height: 18px;
}
&:hover {
background: #e86064;
}
} }
} }
// ─── Meta tablo ───────────────────────────────────────────────────
.productMeta { .productMeta {
background: #f5f5f5; border: 1px solid #e5e7eb;
// padding: 16px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 24px; padding: 16px 20px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
gap: 12px;
display: flex;
flex-direction: column;
.metaItem { .metaItem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 8px 16px; padding-bottom: 8px;
border-bottom: 2px solid #ffffff; border-bottom: 1px solid #f1f1f1;
text-decoration: none;
color: inherit;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -98,112 +297,180 @@
.metaLabel { .metaLabel {
color: #000; color: #000;
font-size: 14px; font-size: 14px;
font-weight: 600;
} }
.metaValue { .metaValue {
font-size: 14px; font-size: 14px;
font-weight: 600;
}
}
// ─── Description card ─────────────────────────────────────────────
.descriptionCard {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px 20px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.descriptionCard::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 125px;
pointer-events: none;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.3) 35%,
rgba(255, 255, 255, 0) 35%
);
z-index: 2;
opacity: 0;
transition: opacity 300ms ease;
}
.descriptionCardCollapsed::after {
opacity: 1;
}
.productDescriptionCollapsed + div {
/* Read more butonunu gradient üstünde göstermek için z-index */
position: relative;
z-index: 3;
}
.descriptionHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.descriptionIcon {
width: 32px;
height: 32px;
border-radius: 6px;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 16px;
height: 16px;
stroke: #fff;
fill: none;
}
}
.descriptionTitle {
font-size: 16px;
font-weight: 700;
color: #000;
margin: 0;
}
}
.productDescription {
font-size: 14px;
color: #000;
line-height: 1.7;
}
.productDescriptionCollapsed {
overflow: hidden;
}
.productDescriptionWrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.readMoreBtn {
background: none;
border: none;
color: #fff;
font-weight: 700;
cursor: pointer;
padding: 8px 0 0 0;
font-size: 16px;
display: inline-block;
letter-spacing: 0.5px;
margin-top: 8px;
position: relative;
z-index: 4;
transition: all 150ms ease;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8), 0 1px 2px rgba(0, 0, 0, 0.6);
&:hover {
text-decoration: underline;
opacity: 1;
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.95), 0 1px 3px rgba(0, 0, 0, 0.7);
font-size: 17px;
}
}
// ─── Mobile sticky bar ────────────────────────────────────────────
.productActionsMobile {
display: none;
@media screen and (max-width: 639px) {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
bottom: 59px;
z-index: 50;
background: #fff;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
padding: 10px 16px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
gap: 12px;
border-radius: 8px;
margin-top: 8px;
}
}
.mobilePriceContainer {
display: flex;
flex-direction: column;
gap: 2px;
width: 45%;
.price {
font-size: 20px;
font-weight: 700;
color: #000;
}
.oldPrice {
font-size: 13px;
color: #d32824;
text-decoration: line-through;
font-weight: 500; font-weight: 500;
} }
} }
.Btn {
.mobileBtnContainer {
display: flex; display: flex;
gap: 10px; gap: 8px;
@media screen and (max-width: 639px) { width: 55%;
width: 65%;
}
}
.priceContainer {
display: flex;
align-items: baseline;
gap: 10px;
@media screen and (max-width: 639px) {
flex-direction: column;
gap: 5px;
width: 35%;
align-items: center;
}
}
.productActions {
@media screen and (max-width: 639px) {
display: none !important;
}
}
.productActionsMobile {
@media screen and (min-width: 639px) {
background-color: #fff;
display: none !important;
position: sticky !important;
bottom: 60px !important;
}
}
.productActions,
.productActionsMobile {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
border-top: 1px solid #e5e7eb;
background-color: #fff;
padding: 15px 16px;
@media screen and (max-width: 520px) {
border-bottom: none;
border-top: none;
padding: 0;
}
.price {
font-size: 24px;
font-weight: 700;
color: #000;
@media screen and (max-width: 520px) {
font-size: 20px;
}
}
.oldPrice {
font-size: 16px;
color: #d32824;
text-decoration: line-through;
font-weight: 600;
@media screen and (max-width: 520px) {
font-size: 14px;
}
}
}
.favoriteButton {
height: 36px;
display: flex;
// margin-right: 0.5rem;
justify-content: center;
align-items: center;
border-radius: 0.375rem;
background-color: rgb(255 255 255);
border: 1px solid rgb(237 228 255);
svg {
fill: #888888;
height: 20px;
width: 20px;
}
}
.wishlistButton {
background: #fff;
border: 1px solid #ddd;
&:hover {
background: #f5f5f5;
}
}
@media (max-width: 768px) {
.productGrid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
} }
// ─── Similar products ─────────────────────────────────────────────
.similarProducts { .similarProducts {
margin-top: 48px; margin-top: 40px;
.sectionTitle { .sectionTitle {
font-size: 20px; font-size: 20px;
@@ -216,117 +483,27 @@
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px; gap: 24px;
@media screen and (max-width: 1230px) { @media screen and (max-width: 1230px) {
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
} }
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(228px, 1fr));
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(234px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(234px, 1fr));
gap: 10px; gap: 10px;
} }
@media screen and (max-width: 767px) {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
@media screen and (max-width: 510px) { @media screen and (max-width: 510px) {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
} }
} }
@media (max-width: 768px) { // ─── Misc ─────────────────────────────────────────────────────────
.productSection {
grid-template-columns: 1fr;
}
}
.addToCartButton {
// height: 40px;
display: flex;
padding-left: 0.5rem;
padding-right: 0.5rem;
justify-content: center;
align-items: center;
border-radius: 0.25rem;
border-width: 1px;
width: 100%;
min-width: 158px;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
transition-duration: 150ms;
background-color: #d32824;
border: none;
@media screen and (max-width: 639px) {
min-width: auto;
}
svg {
fill: #fff;
width: 20px;
height: 20px;
}
&:hover {
background-color: #e86064;
cursor: pointer;
}
}
.quantityControls {
min-width: 158px;
display: flex;
align-items: center;
gap: 2.5rem;
background-color: #d32824;
// width: 10rem;
// justify-content: center;
border-radius: 5px;
width: 100%;
@media screen and (max-width: 520px) {
min-width: auto;
gap: 0;
}
span {
color: #fff;
font-weight: 700;
font-size: 16px;
display: flex;
width: 100%;
justify-content: center;
}
.quantityBtn {
width: 100%;
height: 100%;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
svg {
fill: #fff;
width: 20px;
height: 20px;
}
&:hover {
background: #e86064;
}
}
}
.outOfStock {
background-color: #ff4d4f;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modalButton { .modalButton {
// Style for modal buttons
padding: 6px 15px; padding: 6px 15px;
background-color: #1890ff; background-color: #1890ff;
color: white; color: white;
@@ -334,3 +511,21 @@
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.wishlistButton {
background: #fff;
border: 1px solid #ddd;
&:hover {
background: #f5f5f5;
}
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.outOfStock {
background-color: #ff4d4f;
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import styles from "./ProductPage.module.scss"; import styles from "./ProductPage.module.scss";
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io"; import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
import { FaShoppingCart } from "react-icons/fa"; import { FaShoppingCart } from "react-icons/fa";
@@ -26,6 +26,10 @@ import {
import ImageCarousel from "../../components/ProductCard/imageCarousel/index"; import ImageCarousel from "../../components/ProductCard/imageCarousel/index";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import { Result, Button } from "antd"; import { Result, Button } from "antd";
import { div } from "framer-motion/client";
import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const ProductPage = ({ const ProductPage = ({
productProp, productProp,
@@ -38,27 +42,107 @@ const ProductPage = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { productId } = useParams(); const { productId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
data: productResponse, data: productResponse,
error: productError, error: productError,
isLoading: productLoading, isLoading: productLoading,
} = useGetProductByIdQuery(productId); } = useGetProductByIdQuery(productId);
const { const {
data: similarProductsResponse, data: similarProductsResponse,
error: similarProductsError, error: similarProductsError,
isLoading: similarProductsLoading, isLoading: similarProductsLoading,
} = useGetRelatedProductsQuery(productId); } = useGetRelatedProductsQuery(productId);
const product = productResponse?.data; const product = productResponse?.data;
const similarProducts = similarProductsResponse?.data; const similarProducts = similarProductsResponse?.data;
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false); const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
const [addFavorite] = useAddFavoriteMutation(); const [addFavorite] = useAddFavoriteMutation();
const [removeFavorite] = useRemoveFavoriteMutation(); const [removeFavorite] = useRemoveFavoriteMutation();
const { data: favoriteProducts = [] } = useGetFavoritesQuery(); const { data: favoriteProducts = [] } = useGetFavoritesQuery();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [localIsFavorite, setLocalIsFavorite] = useState( const [localIsFavorite, setLocalIsFavorite] = useState(
favoriteProducts.some((fav) => fav.product?.id === product?.id) favoriteProducts.some((fav) => fav.product?.id === product?.id),
); );
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [showReadMore, setShowReadMore] = useState(false);
const [collapsedMaxHeight, setCollapsedMaxHeight] = useState(null);
const descRef = React.useRef(null);
const productInfoRef = React.useRef(null);
const imageColRef = React.useRef(null);
// Ürün değişince desc'i kapat
useEffect(() => {
setIsDescExpanded(false);
}, [productId]);
// Resim kolonu yüksekliği ile desc kolonu yüksekliğini karşılaştır
useEffect(() => {
if (!product?.description) return;
const imageEl = imageColRef.current;
const infoEl = productInfoRef.current;
if (!imageEl || !infoEl) return;
const checkHeights = () => {
const descEl = descRef.current;
if (!descEl) return;
const descTrueH = descEl.scrollHeight;
const descVisibleH = descEl.getBoundingClientRect().height;
// ── Mobil: tek kolon layout, sabit eşik kullan ──────────────────
if (window.innerWidth <= 639) {
const MOBILE_THRESHOLD = 220;
if (descTrueH > MOBILE_THRESHOLD) {
setShowReadMore(true);
setCollapsedMaxHeight(MOBILE_THRESHOLD);
} else {
setShowReadMore(false);
setCollapsedMaxHeight(null);
}
return;
}
// ── Desktop/tablet: resim kolonu yüksekliğiyle karşılaştır ──────
const imageH = imageEl.getBoundingClientRect().height;
if (imageH === 0) return;
const infoCurrentH = infoEl.getBoundingClientRect().height;
// Info kolonunun gerçek (kısıtsız) yüksekliği:
const infoTrueH = infoCurrentH + (descTrueH - descVisibleH);
if (infoTrueH > imageH) {
const overflow = infoTrueH - imageH;
const newDescMaxH = Math.max(descTrueH - overflow, 60);
setShowReadMore(true);
setCollapsedMaxHeight(newDescMaxH);
} else {
setShowReadMore(false);
setCollapsedMaxHeight(null);
}
};
// İlk kontrol (DOM yerleştikten sonra)
const raf = requestAnimationFrame(checkHeights);
const ro = new ResizeObserver(checkHeights);
ro.observe(imageEl);
ro.observe(infoEl);
// Mobil↔desktop geçişi için window resize de dinlenir
window.addEventListener("resize", checkHeights);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", checkHeights);
};
}, [product?.description]);
const { getCartItem } = useCart(); const { getCartItem } = useCart();
const [addToCart] = useAddToCartMutation(); const [addToCart] = useAddToCartMutation();
@@ -73,7 +157,7 @@ const ProductPage = ({
useEffect(() => { useEffect(() => {
const qty = parseInt( const qty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0, cartItem?.quantity || cartItem?.product_quantity || 0,
10 10,
); );
setLocalQuantity(qty); setLocalQuantity(qty);
setPendingQuantity(qty); setPendingQuantity(qty);
@@ -83,7 +167,7 @@ const ProductPage = ({
useEffect(() => { useEffect(() => {
if (Array.isArray(favoriteProducts)) { if (Array.isArray(favoriteProducts)) {
const isFav = favoriteProducts.some( const isFav = favoriteProducts.some(
(fav) => fav.product?.id === product?.id (fav) => fav.product?.id === product?.id,
); );
setLocalIsFavorite(isFav); setLocalIsFavorite(isFav);
} }
@@ -180,10 +264,9 @@ const ProductPage = ({
useEffect(() => { useEffect(() => {
const serverQty = parseInt( const serverQty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0, cartItem?.quantity || cartItem?.product_quantity || 0,
10 10,
); );
// Sadece miktar değiştiyse ve 0'dan büyükse güncelle (0 ise Remove triggerlanır)
if (pendingQuantity === serverQty || pendingQuantity <= 0) { if (pendingQuantity === serverQty || pendingQuantity <= 0) {
return; return;
} }
@@ -197,7 +280,6 @@ const ProductPage = ({
}).unwrap(); }).unwrap();
} catch (error) { } catch (error) {
console.error("Failed to update cart item:", error); console.error("Failed to update cart item:", error);
// Hata durumunda geri al
setLocalQuantity(serverQty); setLocalQuantity(serverQty);
setPendingQuantity(serverQty); setPendingQuantity(serverQty);
} finally { } finally {
@@ -225,12 +307,74 @@ const ProductPage = ({
if (!product) return <div>Can not find product</div>; if (!product) return <div>Can not find product</div>;
const imageUrl = product.media?.[0]?.thumbnail || "";
const categoryName = product.categories?.[0]?.name || "Category"; const categoryName = product.categories?.[0]?.name || "Category";
const categoryId = product.categories?.[0]?.id; const categoryId = product.categories?.[0]?.id;
const handleCategoryClick = (categoryId) => { const handleCategoryClick = (categoryId) => {
navigate(`/category/${categoryId}`); navigate(`/category/${categoryId}`);
}; };
// ── Cart + favorite butonları (desktop purchase card + mobile bar'da ortak) ──
const CartButtons = () => (
<div className={styles.Btn}>
{showFavoriteButton && (
<button
className={styles.favoriteButton}
onClick={handleToggleFavorite}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
onClick={handleQuantityDecrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
fill="white"
/>
</svg>
</button>
<span>{localQuantity}</span>
<button
onClick={handleQuantityIncrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
fill="white"
/>
</svg>
</button>
</div>
) : (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
>
<FaShoppingCart />
</button>
)}
</>
)}
</div>
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -242,9 +386,10 @@ const ProductPage = ({
<span>{product?.name || "Product"}</span> <span>{product?.name || "Product"}</span>
</div> </div>
{/* Product Details */} {/* ── 3 kolon ana section ── */}
<div className={styles.productSection}> <div className={styles.productSection}>
<div className={styles.productImage}> {/* KOLON 1: Resim */}
<div className={styles.productImage} ref={imageColRef}>
<ImageCarousel <ImageCarousel
images={product.media} images={product.media}
altText={product.name} altText={product.name}
@@ -252,181 +397,165 @@ const ProductPage = ({
isDetailView={true} isDetailView={true}
/> />
</div> </div>
<div className={styles.productInfo}>
<h1 className={styles.productTitle}>{product.name}</h1>
<p
className={styles.productDescription}
dangerouslySetInnerHTML={{ __html: product.description }}
></p>
{/* KOLON 2: İsim + Meta + Description */}
<div className={styles.productInfo} ref={productInfoRef}>
{/* Meta tablo */}
<div className={styles.productMeta}> <div className={styles.productMeta}>
<div className={styles.metaItem}> <h1 className={styles.productTitle}>{product.name}</h1>
{/* <div className={styles.metaItem}>
<span className={styles.metaLabel}> <span className={styles.metaLabel}>
{t("product.productCode")} {t("product.productCode")}
</span> </span>
<span className={styles.metaValue}>{product.id}</span> <span className={styles.metaValue}>{product.id}</span>
</div> </div>
{product.barcode && ( {product.barcode && (
<div className={styles.metaItem}> <div className={styles.metaItem}>
<span className={styles.metaLabel}>{t("product.barCode")}</span> <span className={styles.metaLabel}>{t("product.barCode")}</span>
<span className={styles.metaValue}>{product.barcode}</span> <span className={styles.metaValue}>{product.barcode}</span>
</div> </div>
)} )} */}
{product.brand?.name && ( {product.brand?.name && (
<div className={styles.metaItem}> <a
href={`/brands/${product.brand.id}`}
target="_blank"
className={styles.metaItem}
>
<span className={styles.metaLabel}>{t("order.brand")}</span> <span className={styles.metaLabel}>{t("order.brand")}</span>
<span className={styles.metaValue}>{product.brand.name}</span> <span className={styles.metaValue}>{product.brand.name}</span>
</div> </a>
)} )}
{product.channel?.[0]?.name && ( {product.channel?.[0]?.name && (
<div className={styles.metaItem}>
<Link to={`/channel/${product.channel[0].id}`} target="_blank" state={{ clearFilters: true }} className={styles.metaItem}>
<span className={styles.metaLabel}>{t("order.channel")}</span> <span className={styles.metaLabel}>{t("order.channel")}</span>
<span className={styles.metaValue}> <span className={styles.metaValue}>
{product.channel[0].name} {product.channel[0].name}
</span> </span>
</div> </Link>
)}
{product.properties?.length > 0 && (
product.properties.map((prop, index) => (
<div key={`${prop.attribute_id}-${index}`} className={styles.metaItem}>
<span className={styles.metaLabel}>{prop.name}</span>
<span className={styles.metaValue}>{prop.value}</span>
</div>
))
)} )}
</div> </div>
<div className={styles.productActions}>
<div className={styles.priceContainer}>
<span className={styles.price}>{product.price_amount} m.</span>
{/* Description card */}
{product.description && (
<div className={`${styles.descriptionCard} ${!isDescExpanded && showReadMore ? styles.descriptionCardCollapsed : ''}`}>
<div className={styles.descriptionHeader}>
<div className={styles.descriptionIcon}>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<p className={styles.descriptionTitle}>
{t("product.description")}
</p>
</div>
<div className={styles.productDescriptionWrapper}>
<div
ref={descRef}
className={`${styles.productDescription} ${
!isDescExpanded && showReadMore ? styles.productDescriptionCollapsed : ""
}`}
style={
!isDescExpanded && showReadMore && collapsedMaxHeight
? { maxHeight: `${collapsedMaxHeight}px` }
: undefined
}
dangerouslySetInnerHTML={{ __html: product.description }}
/>
{showReadMore && !isDescExpanded && (
<button
className={styles.readMoreBtn}
onClick={() => setIsDescExpanded(true)}
>
{t("product.readMore")}
</button>
)}
{showReadMore && isDescExpanded && (
<button
className={styles.readMoreBtn}
onClick={() => setIsDescExpanded(false)}
>
{t("product.readLess")}
</button>
)}
</div>
</div>
)}
</div>
{/* KOLON 3: Satın alma kartı (sadece desktop/tablet) */}
<div className={styles.purchaseCol}>
<div className={styles.purchaseCard}>
{/* Fiyat */}
<div className={styles.priceRow}>
<span className={styles.priceLabel}>{t("product.price")}:</span>
<div className={styles.priceRight}>
{isPriceZero(product.price_amount) ? (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 600 }}>
<PendingPriceBadge />
</span>
) : (
<>
<span className={styles.price}>{product.price_amount} m.</span>
{product.old_price_amount && (
<span className={styles.oldPrice}>
{product.old_price_amount} m.
</span>
)}
</>
)}
</div>
</div>
{/* Butonlar */}
<CartButtons />
</div>
</div>
</div>
{/* ── Mobile sticky bar ── */}
<div className={styles.productActionsMobile}>
<div className={styles.mobilePriceContainer}>
{isPriceZero(product.price_amount) ? (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 600 }}>
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
</span>
) : (
<>
<span className={styles.price}>{product.price_amount} m.</span>
{product.old_price_amount && ( {product.old_price_amount && (
<span className={styles.oldPrice}> <span className={styles.oldPrice}>
{product.old_price_amount} m. {product.old_price_amount} m.
</span> </span>
)} )}
</div> </>
<div className={styles.Btn}> )}
{showFavoriteButton && ( </div>
<button <div className={styles.mobileBtnContainer}>
className={styles.favoriteButton} <CartButtons />
onClick={handleToggleFavorite}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
onClick={handleQuantityDecrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
fill="white"
></path>
</svg>
</button>
<span>{localQuantity}</span>
<button
onClick={handleQuantityIncrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
fill="white"
></path>
</svg>
</button>
</div>
) : (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
>
<FaShoppingCart />
</button>
)}
</>
)}
</div>
</div>
<div
className={styles.productActionsMobile}
style={{ position: "sticky", bottom: "59px" }}
>
<div className={styles.priceContainer}>
{" "}
<span className={styles.price}>{product.price_amount} m.</span>
<span className={styles.oldPrice}>
{product.old_price_amount} m.
</span>
</div>
<div className={styles.Btn}>
{showFavoriteButton && (
<button
className={styles.favoriteButton}
onClick={handleToggleFavorite}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
onClick={handleQuantityDecrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
fill="white"
></path>
</svg>
</button>
<span>{localQuantity}</span>
<button
onClick={handleQuantityIncrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
fill="white"
></path>
</svg>
</button>
</div>
) : (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
>
<FaShoppingCart />
</button>
)}
</>
)}
</div>
</div>
</div> </div>
</div> </div>
{/* Reviews */}
<ReviewSection <ReviewSection
productId={productId} productId={productId}
existingReviews={product.reviews_resources} existingReviews={product.reviews_resources}
@@ -452,6 +581,7 @@ const ProductPage = ({
</div> </div>
</div> </div>
{/* Stock modal */}
<Modal <Modal
title={t("common.warning")} title={t("common.warning")}
open={stockErrorModalVisible} open={stockErrorModalVisible}
@@ -480,3 +610,4 @@ const ProductPage = ({
}; };
export default ProductPage; export default ProductPage;

View File

@@ -0,0 +1,143 @@
.storesContainer {
padding: 1rem 4.4rem;
max-width: 1336px;
margin: 0 auto;
@media screen and (max-width: 1023px) {
padding: 1rem 1.25rem;
}
}
.searchWrapper {
margin-bottom: 24px;
display: flex;
align-items: center;
height: 40px;
svg {
position: absolute;
width: 24px;
height: 24px;
transform: translateX(35%);
color: #9ca3af;
}
input {
width: 46%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
padding-left: 40px;
outline: none;
@media screen and (max-width: 1023px) {
width: 100%;
}
&::placeholder {
color: #9ca3af;
}
}
}
.categorySection {
margin-bottom: 40px;
h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #111827;
cursor: pointer;
&:hover {
color: #aaaaaa;
}
}
}
.storesGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
gap: 16px;
@media screen and (max-width: 1023px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
@media screen and (max-width: 900px) {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 5px;
}
@media screen and (max-width: 798px) {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 5px;
}
}
.storeCard {
background: white;
border-radius: 8px;
padding: 16px;
text-align: center;
transition: all 0.2s ease;
border: 1px solid #e5e7eb;
cursor: pointer;
@media screen and (max-width: 900px) {
padding: 5px;
}
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.imageWrapper {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
position: relative;
aspect-ratio: 4/3;
img {
height: 100%;
object-fit:contain;
}
}
.placeholder {
width: 80px;
height: 80px;
background: #f3f4f6;
border-radius: 8px;
}
.storeName {
font-size: 16px;
color: #374151;
margin: 0;
}
}
.skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.logoFallback {
display: flex;
justify-content: center;
align-items: center;
width: 120px;
height: 100%;
border-radius: 4px;
object-fit: contain;
}

202
src/pages/Stores/index.jsx Normal file
View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import InfiniteScroll from "react-infinite-scroll-component";
import { useLazyGetChannelsQuery } from "../../app/api/channelsApi";
import styles from "./Stores.module.scss";
import { CiSearch } from "react-icons/ci";
import { useNavigate } from "react-router-dom";
import { Logo } from "../../components/Icons";
import Loader from "../../components/Loader/index";
import { Result, Button } from "antd";
const StoresPage = () => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const navigate = useNavigate();
// Stores data state
const [allStores, setAllStores] = useState([]);
const [visibleStores, setVisibleStores] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const itemsPerPage = 24;
// Use lazy query to have more control over when to fetch
const [getChannels, { data: channelsData, isLoading, isFetching, error }] =
useLazyGetChannelsQuery();
// Initial fetch on component mount
useEffect(() => {
getChannels({ page: 1, perPage: itemsPerPage });
}, [getChannels]);
// Process stores data when it arrives
useEffect(() => {
if (channelsData) {
const stores = channelsData.data || [];
const pagination = channelsData.pagination || {};
console.log("Stores Data Received:", {
count: stores.length,
page,
pagination
});
setAllStores((prev) => {
const existingIds = new Set(prev.map((store) => store.id));
const newStores = stores.filter(
(store) => !existingIds.has(store.id)
);
return [...prev, ...newStores];
});
// More robust hasMore logic
const hasNext = pagination.next_page_url ||
(pagination.current_page && pagination.last_page && pagination.current_page < pagination.last_page) ||
(stores.length === itemsPerPage);
setHasMore(!!hasNext);
}
}, [channelsData, page, itemsPerPage]);
// Process stores for display whenever all stores or search term changes
useEffect(() => {
if (allStores.length > 0) {
const filteredStores = searchTerm
? allStores.filter((store) =>
store.name.toLowerCase().includes(searchTerm.toLowerCase())
)
: allStores;
// Grouping logic (similar to Brands, but defaults to "Stores")
const groupedStores = filteredStores.reduce((acc, store) => {
const type = store.type || "Stores";
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(store);
return acc;
}, {});
const displayGroups = Object.entries(groupedStores)
.map(([type, stores]) => ({
title: type === "Stores" ? t("navbar.stores") : type.charAt(0).toUpperCase() + type.slice(1),
stores,
}))
.filter((group) => group.stores.length > 0);
setVisibleStores(displayGroups);
if (searchTerm) {
setHasMore(false);
}
}
}, [allStores, searchTerm, t]);
const loadMoreStores = () => {
if (!searchTerm && !isFetching && hasMore && allStores.length > 0) {
const nextPage = page + 1;
getChannels({ page: nextPage, perPage: itemsPerPage });
setPage(nextPage);
}
};
const handleSearch = (e) => {
const value = e.target.value;
setSearchTerm(value);
setPage(1);
setAllStores([]);
setHasMore(true);
getChannels({ page: 1, perPage: itemsPerPage, search: value });
};
const handleStoreClick = (storeId) => {
navigate(`/channel/${storeId}`);
};
if (isLoading && page === 1) return <Loader />;
if (error)
return (
<Result
status="500"
title="500"
subTitle={t("common.error_occurred") || "Näbelli ýalňyşlyk ýüze çykdy."}
extra={
<Button type="primary" onClick={() => navigate("/")}>
{t("common.back_to_home") || "Baş sahypa gidiň"}
</Button>
}
/>
);
return (
<div className={styles.storesContainer}>
<div className={styles.searchWrapper}>
<CiSearch />
<input
type="text"
placeholder={t("common.search")}
value={searchTerm}
onChange={handleSearch}
/>
</div>
<InfiniteScroll
dataLength={allStores.length}
next={loadMoreStores}
hasMore={hasMore && !searchTerm}
loader={<div style={{ textAlign: 'center', padding: '20px' }}><Loader /></div>}
>
{visibleStores.map((group, index) => (
<section key={index} className={styles.categorySection}>
<h2>{group.title}</h2>
<div className={styles.storesGrid}>
{group.stores.map((store) => (
<div
key={store.id}
className={styles.storeCard}
onClick={() => handleStoreClick(store.id)}
>
<div className={styles.imageWrapper}>
{store.media?.[0]?.thumbnail ||
store.media?.[0]?.images_800x800 ||
store.logo ? (
<img
src={
store.media?.[0]?.thumbnail ||
store.media?.[0]?.images_800x800 ||
store.logo
}
alt={store.name}
width={120}
height={120}
onError={(e) => {
e.target.style.display = "none";
e.target.nextSibling.style.display = "flex";
}}
/>
) : (
<div className={styles.logoFallback}>
<Logo width={60} height={60} />
</div>
)}
<div
className={styles.logoFallback}
style={{ display: "none" }}
>
<Logo width={60} height={60} />
</div>
</div>
<div className={styles.divider}></div>
<h3 className={styles.storeName}>{store.name}</h3>
</div>
))}
</div>
</section>
))}
</InfiniteScroll>
</div>
);
};
export default StoresPage;

View File

@@ -2,6 +2,9 @@ import React, { useState, useEffect } from "react";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import CategorySection from "../../components/CategorySection/index"; import CategorySection from "../../components/CategorySection/index";
import Carousel from "../../components/Banner/index"; import Carousel from "../../components/Banner/index";
import CategoryCarousel from "../../components/CategoryCarousel/CategoryCarousel";
import HomeBrands from "../../components/HomeBrands/index";
import FlashSales from "../../components/FlashSales";
import styles from "./Home.module.scss"; import styles from "./Home.module.scss";
import { useGetCollectionsQuery } from "../../app/api/collectionsApi"; import { useGetCollectionsQuery } from "../../app/api/collectionsApi";
import PageLoader from "../../components/Loader/pageLoader"; import PageLoader from "../../components/Loader/pageLoader";
@@ -20,7 +23,6 @@ const Home = () => {
const processCollections = async (collectionsData) => { const processCollections = async (collectionsData) => {
if (!collectionsData || !collectionsData.data) return []; if (!collectionsData || !collectionsData.data) return [];
// Cache the processed collections to prevent duplicate processing
const collectionsWithProducts = []; const collectionsWithProducts = [];
for (const collection of collectionsData.data) { for (const collection of collectionsData.data) {
@@ -44,8 +46,6 @@ const Home = () => {
}; };
const checkIfCollectionHasProducts = async (collectionId) => { const checkIfCollectionHasProducts = async (collectionId) => {
// This is a placeholder - your actual implementation would check if products exist
// For now, we just return true as in your original code
return true; return true;
}; };
@@ -71,7 +71,6 @@ const Home = () => {
setPage(page + 1); setPage(page + 1);
} }
// Check if we've loaded all collections
if (endIndex >= collections.length) { if (endIndex >= collections.length) {
setHasMore(false); setHasMore(false);
} }
@@ -80,7 +79,6 @@ const Home = () => {
} }
}; };
// if (isLoading) return <PageLoader />;
if (error) if (error)
return ( return (
<div> <div>
@@ -100,6 +98,9 @@ const Home = () => {
return ( return (
<div className={styles.home}> <div className={styles.home}>
<Carousel /> <Carousel />
<CategoryCarousel />
<HomeBrands />
<FlashSales />
<div className={styles.sections}> <div className={styles.sections}>
<InfiniteScroll <InfiniteScroll
dataLength={visibleCollections.length} dataLength={visibleCollections.length}
@@ -113,7 +114,7 @@ const Home = () => {
<CategorySection <CategorySection
key={collection.id} key={collection.id}
collection={collection} collection={collection}
preventEmptyRender={true} // Add a prop to prevent rendering empty collections preventEmptyRender={true}
/> />
))} ))}
</InfiniteScroll> </InfiniteScroll>
@@ -122,4 +123,4 @@ const Home = () => {
); );
}; };
export default Home; export default Home;

View File

@@ -20,6 +20,8 @@ const ContactUs = lazy(() => import("./pages/ContactUs/index.jsx"));
const DeliveryTerms = lazy(() => import("./pages/DeliveryTerms/index.jsx")); const DeliveryTerms = lazy(() => import("./pages/DeliveryTerms/index.jsx"));
const AboutUs = lazy(() => import("./pages/AboutUs/index.jsx")); const AboutUs = lazy(() => import("./pages/AboutUs/index.jsx"));
const PrivacyPolicy = lazy(() => import("./pages/PrivacyPolicy/index.jsx")); const PrivacyPolicy = lazy(() => import("./pages/PrivacyPolicy/index.jsx"));
const AdminPage = lazy(() => import("./pages/CarconfiguratorAdmin/index.jsx"));
const StoresPage = lazy(() => import("./pages/Stores/index.jsx"));
export default function Router() { export default function Router() {
const routes = useRoutes([ const routes = useRoutes([
@@ -33,12 +35,14 @@ export default function Router() {
children: [ children: [
{ path: "/", element: <Home /> }, { path: "/", element: <Home /> },
{ path: "/brands", element: <BrandsPage /> }, { path: "/brands", element: <BrandsPage /> },
{ path: "/stores", element: <StoresPage /> },
{ path: "/brands/:brandId", element: <Category /> }, { path: "/brands/:brandId", element: <Category /> },
{ path: "/cart", element: <CartPage /> }, { path: "/cart", element: <CartPage /> },
{ path: "/wishlist", element: <WishList /> }, { path: "/wishlist", element: <WishList /> },
{ path: "/category/:categoryId", element: <Category /> }, { path: "/category/:categoryId", element: <Category /> },
{ path: "/search", element: <Category /> }, { path: "/search", element: <Category /> },
{ path: "/collections/:collectionId", element: <Category /> }, { path: "/collections/:collectionId", element: <Category /> },
{ path: "/channel/:channelId", element: <Category /> },
{ path: "/product/:productId", element: <ProductDetail /> }, { path: "/product/:productId", element: <ProductDetail /> },
{ path: "/profile", element: <ProfileMenu /> }, { path: "/profile", element: <ProfileMenu /> },
{ path: "/orders", element: <Orders /> }, { path: "/orders", element: <Orders /> },
@@ -47,6 +51,7 @@ export default function Router() {
{ path: "/delivery-and-payment", element: <DeliveryTerms /> }, { path: "/delivery-and-payment", element: <DeliveryTerms /> },
{ path: "/about-us", element: <AboutUs /> }, { path: "/about-us", element: <AboutUs /> },
{ path: "/privacy-policy", element: <PrivacyPolicy /> }, { path: "/privacy-policy", element: <PrivacyPolicy /> },
{ path: "/carconfigurator-admin", element: <AdminPage /> },
], ],
}, },
]); ]);

View File

@@ -1,11 +1,39 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import fs from 'fs';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
{
name: "data-api",
configureServer(server) {
server.middlewares.use("/frontend-api/data", (req, res, next) => {
if (req.method === "POST") {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
fs.writeFile("public/data.json", body, (err) => {
if (err) { res.statusCode = 500; res.end(err.message); }
else { res.statusCode = 200; res.end(JSON.stringify({ ok: true })); }
});
});
return;
}
if (req.method === "GET") {
fs.readFile("public/data.json", "utf-8", (err, data) => {
if (err) { res.statusCode = 500; res.end(err.message); }
else { res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end(data); }
});
return;
}
next();
});
},
},
],
server: { server: {
host: true, host: true,
}, },
}) })