Compare commits
25 Commits
f9e3ec37f8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3079801b | ||
|
|
684ab6917d | ||
|
|
6cdde96c61 | ||
|
|
9419ec0af0 | ||
|
|
cc89455967 | ||
|
|
7060d05ae9 | ||
|
|
58287469ac | ||
|
|
29429f7139 | ||
|
|
d92ec369a9 | ||
|
|
76c819848b | ||
|
|
a2298dfa9c | ||
|
|
6ef7aa3c47 | ||
|
|
68382648a8 | ||
|
|
808506832c | ||
|
|
aa3c333192 | ||
|
|
ec8eb4fee4 | ||
|
|
33214002f2 | ||
|
|
696853e988 | ||
|
|
3156e25068 | ||
|
|
7c75205077 | ||
|
|
7dc9a5fbc7 | ||
|
|
99ad4fa1e0 | ||
|
|
c4ac669a09 | ||
|
|
6ba07d7fef | ||
|
|
a7e31b380d |
10
ecosystem.config.js
Normal 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
@@ -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
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"antd": "^5.22.7",
|
||||
"express": "^5.2.1",
|
||||
"framer-motion": "^12.5.0",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
@@ -1079,17 +1080,6 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
@@ -1897,6 +1900,30 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"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": {
|
||||
"version": "1.1.11",
|
||||
"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==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
@@ -1975,7 +2004,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
|
||||
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -1988,7 +2016,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
|
||||
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"get-intrinsic": "^1.2.6"
|
||||
@@ -2074,13 +2101,6 @@
|
||||
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
|
||||
@@ -2092,6 +2112,28 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2106,6 +2148,15 @@
|
||||
"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": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
|
||||
@@ -2201,10 +2252,10 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"dev": true,
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -2257,6 +2308,15 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -2273,7 +2333,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -2283,12 +2342,27 @@
|
||||
"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": {
|
||||
"version": "1.5.76",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
|
||||
"integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==",
|
||||
"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": {
|
||||
"version": "1.23.9",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
|
||||
@@ -2358,7 +2432,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -2367,7 +2440,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -2403,7 +2475,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
|
||||
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -2501,6 +2572,12 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2713,6 +2790,67 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2743,6 +2881,27 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -2787,6 +2946,15 @@
|
||||
"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": {
|
||||
"version": "12.5.0",
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2831,7 +3008,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -2878,7 +3054,6 @@
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
||||
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -2902,7 +3077,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -2972,7 +3146,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -3032,7 +3205,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -3059,7 +3231,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -3075,6 +3246,26 @@
|
||||
"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": {
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
|
||||
@@ -3114,6 +3305,22 @@
|
||||
"@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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3174,6 +3381,12 @@
|
||||
"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": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz",
|
||||
@@ -3197,6 +3410,15 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
@@ -3732,11 +3960,56 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -3765,8 +4038,7 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
@@ -3792,6 +4064,15 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"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": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -6474,7 +6755,6 @@
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
|
||||
"integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -6561,6 +6841,27 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -6637,6 +6938,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -6661,6 +6971,16 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -6724,6 +7044,19 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6733,6 +7066,45 @@
|
||||
"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": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.30.0.tgz",
|
||||
@@ -7595,6 +7967,22 @@
|
||||
"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": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -7656,6 +8044,12 @@
|
||||
"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": {
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.83.0.tgz",
|
||||
@@ -8060,6 +8454,51 @@
|
||||
"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": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
@@ -8111,6 +8550,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -8136,7 +8581,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -8155,7 +8599,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
@@ -8171,7 +8614,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -8189,7 +8631,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -8204,16 +8645,6 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -8223,15 +8654,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
@@ -8425,6 +8854,15 @@
|
||||
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -8447,6 +8885,20 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.1",
|
||||
@@ -8599,6 +9053,15 @@
|
||||
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
|
||||
"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": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
|
||||
@@ -8787,6 +9250,12 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"antd": "^5.22.7",
|
||||
"express": "^5.2.1",
|
||||
"framer-motion": "^12.5.0",
|
||||
"i18next": "^24.2.1",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
|
||||
1
public/data.json
Normal file
41
server.js
Normal 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`);
|
||||
});
|
||||
@@ -8,7 +8,10 @@ export const mediaApi = baseApi.injectEndpoints({
|
||||
getBanners: builder.query({
|
||||
query: () => '/media/banners',
|
||||
}),
|
||||
getStories: builder.query({
|
||||
query: () => '/media/stories',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetCarouselsQuery, useGetBannersQuery } = mediaApi;
|
||||
export const { useGetCarouselsQuery, useGetBannersQuery, useGetStoriesQuery } = mediaApi;
|
||||
@@ -5,16 +5,9 @@ export const brandsApi = baseApi.injectEndpoints({
|
||||
getBrands: builder.query({
|
||||
query: (params = {}) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.type) {
|
||||
queryParams.append("type", params.type);
|
||||
}
|
||||
if (params.page) {
|
||||
queryParams.append("page", params.page);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append("limit", params.limit);
|
||||
}
|
||||
|
||||
if (params.type) queryParams.append("type", params.type);
|
||||
if (params.page) queryParams.append("page", params.page);
|
||||
if (params.perPage) queryParams.append("perPage", params.perPage);
|
||||
const queryString = queryParams.toString();
|
||||
return `/brands${queryString ? `?${queryString}` : ""}`;
|
||||
},
|
||||
@@ -28,18 +21,19 @@ export const brandsApi = baseApi.injectEndpoints({
|
||||
|
||||
getBrandProducts: builder.query({
|
||||
query: (params) => {
|
||||
if (typeof params === 'string' || typeof params === 'number') {
|
||||
if (typeof params === "string" || typeof params === "number") {
|
||||
return `/brands/${params}/products`;
|
||||
}
|
||||
|
||||
const { id, page = 1, limit } = params;
|
||||
let url = `/brands/${id}/products?page=${page}`;
|
||||
const { id, page = 1, perPage = 12, sorting, min_price, max_price } = params;
|
||||
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) {
|
||||
url += `&limit=${limit}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
return `/brands/${id}/products?${urlParams.toString()}`;
|
||||
},
|
||||
transformResponse: (response) => ({
|
||||
data: response.data || response,
|
||||
|
||||
@@ -5,16 +5,16 @@ export const categoriesApi = baseApi.injectEndpoints({
|
||||
getCategories: builder.query({
|
||||
query: (type = "tree") => `/categories?type=${type}`,
|
||||
}),
|
||||
|
||||
|
||||
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();
|
||||
params.append('page', page);
|
||||
if (limit) params.append('limit', limit);
|
||||
if (brands) params.append('brands', brands);
|
||||
if (min_price) params.append('min_price', min_price);
|
||||
if (max_price) params.append('max_price', max_price);
|
||||
|
||||
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 `categories/${categoryId}/products?${params.toString()}`;
|
||||
},
|
||||
transformResponse: (response) => ({
|
||||
@@ -22,79 +22,105 @@ export const categoriesApi = baseApi.injectEndpoints({
|
||||
pagination: response.pagination || {},
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
getAllCategoryProducts: builder.query({
|
||||
async queryFn(category, queryApi, extraOptions, baseQuery) {
|
||||
async queryFn(category, _queryApi, _extraOptions, baseQuery) {
|
||||
const fetchProducts = async (categoryId) => {
|
||||
const result = await baseQuery(`categories/${categoryId}/products`);
|
||||
return result.data ? result.data.data : [];
|
||||
};
|
||||
|
||||
let allProducts = await fetchProducts(category.id);
|
||||
|
||||
for (const child of category.children) {
|
||||
const childProducts = await fetchProducts(child.id);
|
||||
allProducts = [...allProducts, ...childProducts];
|
||||
}
|
||||
|
||||
return { data: allProducts };
|
||||
},
|
||||
}),
|
||||
|
||||
getAllCategoryProductsPaginated: builder.query({
|
||||
async queryFn(
|
||||
{ category, page = 1, limit = 6, brands, min_price, max_price },
|
||||
queryApi,
|
||||
extraOptions,
|
||||
{ category, page = 1, perPage = 12, brands, min_price, max_price, sorting },
|
||||
_queryApi,
|
||||
_extraOptions,
|
||||
baseQuery
|
||||
) {
|
||||
if (!category) return { data: [] };
|
||||
if (!category) return { data: { data: [], pagination: { currentPage: 1, hasMorePages: false } } };
|
||||
|
||||
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];
|
||||
if (category.children && category.children.length > 0) {
|
||||
if (category.children?.length > 0) {
|
||||
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(
|
||||
(hasMore) => hasMore
|
||||
);
|
||||
const result = await baseQuery(
|
||||
`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 {
|
||||
data: {
|
||||
data: productsForPage,
|
||||
data: allProducts,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
hasMorePages: hasMorePages,
|
||||
hasMorePages,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -103,11 +129,11 @@ export const categoriesApi = baseApi.injectEndpoints({
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
getProductById: builder.query({
|
||||
query: (productId) => `/products/${productId}`,
|
||||
}),
|
||||
|
||||
|
||||
getRelatedProducts: builder.query({
|
||||
query: (productId) => `/products/${productId}/related`,
|
||||
}),
|
||||
|
||||
53
src/app/api/channelsApi.js
Normal 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;
|
||||
@@ -5,39 +5,35 @@ export const collectionsApi = baseApi.injectEndpoints({
|
||||
getCollections: builder.query({
|
||||
query: () => `/collections`,
|
||||
}),
|
||||
|
||||
|
||||
getCollectionById: builder.query({
|
||||
query: (collectionId) => `/collections/${collectionId}`,
|
||||
}),
|
||||
|
||||
|
||||
getCollectionProducts: builder.query({
|
||||
query: (collectionId) => `/collections/${collectionId}/products`,
|
||||
transformResponse: (response) => {
|
||||
return {
|
||||
data: response.data || [],
|
||||
isEmpty: !response.data || response.data.length === 0,
|
||||
};
|
||||
},
|
||||
transformResponse: (response) => ({
|
||||
data: response.data || [],
|
||||
isEmpty: !response.data || response.data.length === 0,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
checkCollectionHasProducts: builder.query({
|
||||
query: (collectionId) => `/collections/${collectionId}/products?limit=1`,
|
||||
transformResponse: (response) => {
|
||||
return {
|
||||
hasProducts: response.data && response.data.length > 0,
|
||||
};
|
||||
},
|
||||
query: (collectionId) => `/collections/${collectionId}/products`,
|
||||
transformResponse: (response) => ({
|
||||
hasProducts: response.data && response.data.length > 0,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
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();
|
||||
params.append('page', page);
|
||||
if (limit) params.append('limit', limit);
|
||||
if (brands) params.append('brands', brands);
|
||||
if (min_price) params.append('min_price', min_price);
|
||||
if (max_price) params.append('max_price', max_price);
|
||||
|
||||
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); // undefined gelirse gönderme
|
||||
return `/collections/${collectionId}/products?${params.toString()}`;
|
||||
},
|
||||
transformResponse: (response) => ({
|
||||
|
||||
@@ -15,6 +15,9 @@ export const filtersApi = baseApi.injectEndpoints({
|
||||
if (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()}`
|
||||
},
|
||||
@@ -22,6 +25,7 @@ export const filtersApi = baseApi.injectEndpoints({
|
||||
return {
|
||||
categories: response.data?.categories || [],
|
||||
brands: response.data?.brands || [],
|
||||
channels: response.data?.channels || [],
|
||||
}
|
||||
},
|
||||
keepUnusedDataFor: 300,
|
||||
@@ -40,7 +44,9 @@ export const filtersApi = baseApi.injectEndpoints({
|
||||
if (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';
|
||||
},
|
||||
|
||||
@@ -66,7 +72,9 @@ export const filtersApi = baseApi.injectEndpoints({
|
||||
if (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;
|
||||
},
|
||||
}),
|
||||
|
||||
11
src/app/api/flashSalesApi.js
Normal 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;
|
||||
BIN
src/assets/arch_inter_side.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/arch_street_side.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/door.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/engine.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/floor.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/hood.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/maincar.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src/assets/plastik_fender_liner_4.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/roof.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/trunk_flor.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/trunk_lid.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
109
src/components/Banner/Stories.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/components/Banner/hook/useDragScroll.js
Normal 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;
|
||||
}
|
||||
@@ -1,197 +1,152 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import {
|
||||
Autoplay,
|
||||
Thumbs,
|
||||
Pagination,
|
||||
Navigation,
|
||||
Mousewheel,
|
||||
FreeMode,
|
||||
} from "swiper/modules";
|
||||
import { Autoplay, Thumbs, Pagination, Navigation, Mousewheel, FreeMode } from "swiper/modules";
|
||||
import { Skeleton } from "antd";
|
||||
|
||||
import "swiper/css";
|
||||
import "swiper/css/pagination";
|
||||
import "swiper/css/thumbs";
|
||||
import "swiper/css/navigation";
|
||||
|
||||
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() {
|
||||
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 [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const thumbSliderRef = useRef(null);
|
||||
const [selectedStoryIndex, setSelectedStoryIndex] = useState(null);
|
||||
const [viewedStoryIds, setViewedStoryIds] = useState(new Set());
|
||||
|
||||
const storiesScrollRef = useDragScroll();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnimating(false);
|
||||
setTimeout(() => setIsAnimating(true), 50);
|
||||
const timer = setTimeout(() => setIsAnimating(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [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 newActiveIndex = swiper.realIndex;
|
||||
setActiveIndex(newActiveIndex);
|
||||
setActiveIndex(swiper.realIndex);
|
||||
};
|
||||
|
||||
if (thumbsSwiper?.slides) {
|
||||
const slidesPerView = 4;
|
||||
let targetIndex = newActiveIndex - Math.floor(slidesPerView / 2);
|
||||
targetIndex = Math.max(
|
||||
0,
|
||||
Math.min(targetIndex, thumbsSwiper.slides.length - slidesPerView)
|
||||
);
|
||||
const handleImageClick = (link) => {
|
||||
if (link) window.open(link, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
thumbsSwiper.slideTo(targetIndex, 300);
|
||||
updateScrollPosition(targetIndex);
|
||||
const handleStoryViewed = (storyIndex) => {
|
||||
const storyId = storiesData?.data[storyIndex]?.id;
|
||||
if (storyId) {
|
||||
setViewedStoryIds((prev) => new Set(prev).add(storyId));
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for clicking on carousel images
|
||||
const handleImageClick = (link) => {
|
||||
if (link) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
const handleStoryClick = (index) => {
|
||||
if (storiesScrollRef.current?.dataset.dragged) return;
|
||||
setSelectedStoryIndex(index);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.carouselContainer}>
|
||||
{/* Main slider skeleton */}
|
||||
<div className={`${styles.mainSlider} skeleton-main-slider`}>
|
||||
<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 className={styles.mainSliderSkeleton}>
|
||||
<Skeleton.Image active className={styles.fullWidthSkeleton} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data || !data.data || data.data.length === 0) {
|
||||
return <div>No images available</div>;
|
||||
}
|
||||
if (isError || !carouselData?.data?.length) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.carouselContainer}>
|
||||
{/* Main Slider */}
|
||||
<Swiper
|
||||
modules={[Thumbs, Pagination, Navigation, Autoplay]}
|
||||
thumbs={{ swiper: thumbsSwiper }}
|
||||
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||
loop={true}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
}}
|
||||
navigation={true}
|
||||
className={styles.mainSlider}
|
||||
onSlideChange={handleSlideChange}
|
||||
>
|
||||
{data.data.map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<div
|
||||
className={styles.imageWrapper}
|
||||
onClick={() => handleImageClick(item.link)}
|
||||
style={{ cursor: item.link ? 'pointer' : 'default' }}
|
||||
>
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title || `Carousel Image ${item.id}`}
|
||||
/>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<>
|
||||
{!isStoriesLoading && !isStoriesError && storiesData?.data?.length > 0 && (
|
||||
<div className={storiesStyles.storiesContainer}>
|
||||
<div className={storiesStyles.storiesWrapper} ref={storiesScrollRef}>
|
||||
{storiesData.data.map((story, index) => {
|
||||
const isViewed = viewedStoryIds.has(story.id);
|
||||
return (
|
||||
|
||||
<button
|
||||
key={story.id}
|
||||
className={storiesStyles.storyButton}
|
||||
onClick={() => handleStoryClick(index)}
|
||||
>
|
||||
<div className={`${storiesStyles.storyAvatar} ${isViewed ? storiesStyles.viewed : ""}`}>
|
||||
<img src={story.thumbnail || story.photo} alt={story.title} />
|
||||
</div>
|
||||
<span className={storiesStyles.storyLabel}>{story.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Slider */}
|
||||
<Swiper
|
||||
ref={thumbSliderRef}
|
||||
modules={[Thumbs, Autoplay, FreeMode, Mousewheel]}
|
||||
onSwiper={setThumbsSwiper}
|
||||
autoplay={{ delay: 3000 }}
|
||||
slidesPerView={4}
|
||||
spaceBetween={10}
|
||||
direction="vertical"
|
||||
watchSlidesProgress={true}
|
||||
slideToClickedSlide={true}
|
||||
cssMode={true}
|
||||
loop={false}
|
||||
allowTouchMove={true}
|
||||
className={styles.thumbSlider}
|
||||
>
|
||||
{data.data.map((item, index) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<div
|
||||
className={`${styles.thumbWrapper} ${
|
||||
index === activeIndex ? styles.active : ""
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.title || `Thumbnail ${index + 1}`}
|
||||
/>
|
||||
{index === activeIndex && isAnimating && (
|
||||
<>
|
||||
<div className={styles.progressBarImg}></div>
|
||||
<div className={styles.progressBar}></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
{selectedStoryIndex !== null && (
|
||||
<StoryViewer
|
||||
stories={storiesData.data}
|
||||
initialIndex={selectedStoryIndex}
|
||||
onClose={() => setSelectedStoryIndex(null)}
|
||||
onStoryViewed={handleStoryViewed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.carouselContainer}>
|
||||
<Swiper
|
||||
modules={[Thumbs, Pagination, Navigation, Autoplay]}
|
||||
thumbs={{ swiper: thumbsSwiper && !thumbsSwiper.destroyed ? thumbsSwiper : null }}
|
||||
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||
loop={true}
|
||||
pagination={{ clickable: true }}
|
||||
navigation={true}
|
||||
className={styles.mainSlider}
|
||||
onSlideChange={handleSlideChange}
|
||||
>
|
||||
{carouselData.data.map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<div
|
||||
className={styles.imageWrapper}
|
||||
onClick={() => handleImageClick(item.link)}
|
||||
style={{ cursor: item.link ? "pointer" : "default" }}
|
||||
>
|
||||
<img src={item.image} alt={item.title || "Banner"} />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<Swiper
|
||||
modules={[Thumbs, FreeMode, Mousewheel]}
|
||||
onSwiper={setThumbsSwiper}
|
||||
slidesPerView={4}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
212
src/components/CarConfigurator/Carconfigurator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
src/components/CarConfigurator/Carconfigurator.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/components/CategoryCarousel/CategoryCarousel.jsx
Normal 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;
|
||||
151
src/components/CategoryCarousel/CategorySlider.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,116 @@
|
||||
// DropdownMenu.module.scss
|
||||
|
||||
.dropdownContainer {
|
||||
position: relative;
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TRIGGER BUTTON ----
|
||||
.navButton {
|
||||
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;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
padding: 0.25rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownWrapper {
|
||||
position: relative;
|
||||
}
|
||||
&.navButtonActive {
|
||||
background-color: #e63946;
|
||||
color: #ffffff;
|
||||
|
||||
.dropdownPanel {
|
||||
position: absolute;
|
||||
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;
|
||||
svg {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
background: #d1d5db;
|
||||
}
|
||||
.title {
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
color: #888888;
|
||||
}
|
||||
&:active {
|
||||
color: #888888;
|
||||
background: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,156 +118,169 @@
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
justify-content: space-between;
|
||||
padding: 9px 16px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: 1px solid #3615371a;
|
||||
border-radius: 6px;
|
||||
color: #000;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
background-color: #f3f4f6;
|
||||
color: #e63946;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
color: #e63946;
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
color: #888888;
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---- RIGHT CONTENT PANEL ----
|
||||
.contentPanel {
|
||||
flex: 3;
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
|
||||
// &::-webkit-scrollbar {
|
||||
// width: 6px;
|
||||
// }
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #e5e7eb;
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
// border-radius: 3px;
|
||||
}
|
||||
.title {
|
||||
cursor: pointer;
|
||||
color: #361517;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
color: #888888;
|
||||
background: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2;
|
||||
text-align: left;
|
||||
|
||||
.panelTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
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;
|
||||
color: #361517;
|
||||
padding: 4px 0;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #888888;
|
||||
color: #e63946;
|
||||
}
|
||||
}
|
||||
|
||||
.subCategoriesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nestedCategoryContainer:last-child {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nestedCategoryContainer {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nestedCategoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
.leafItem {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
padding: 3px 0;
|
||||
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 {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.categoryLabel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.expandButton,
|
||||
.navigateButton {
|
||||
.navButtonLoading {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
|
||||
.categoryIcon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingDots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #e5e7eb;
|
||||
span {
|
||||
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 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.noSubcategories {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
@keyframes dotPulse {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@@ -1,83 +1,62 @@
|
||||
// DropdownMenu.jsx
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import styles from "./DropdownMenu.module.scss";
|
||||
import { useGetCategoriesQuery } from "../../app/api/categories";
|
||||
import { CategoryIcon } from "../Icons";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react"; // Assuming you have access to lucide-react or similar
|
||||
|
||||
const NestedCategory = ({
|
||||
category,
|
||||
level = 0,
|
||||
handleCategorySelect,
|
||||
closeDropdown,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasChildren = category.children && category.children.length > 0;
|
||||
const ContentPanel = ({ category, onSelect, onClose }) => {
|
||||
if (!category) return null;
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (hasChildren) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else {
|
||||
handleCategorySelect(category);
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
const children = category.children || [];
|
||||
const withChildren = children.filter((c) => c.children?.length > 0);
|
||||
const withoutChildren = children.filter((c) => !c.children?.length);
|
||||
|
||||
const handleDirectNavigation = (e) => {
|
||||
e.stopPropagation();
|
||||
handleCategorySelect(category);
|
||||
closeDropdown();
|
||||
};
|
||||
const allColumns = [
|
||||
...withChildren,
|
||||
...withoutChildren.map((c) => ({ ...c, children: [] })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.nestedCategoryContainer}
|
||||
style={{ paddingLeft: `${level * 16}px` }}
|
||||
>
|
||||
<div className={styles.nestedCategoryItem} onClick={handleClick}>
|
||||
<div className={styles.categoryLabel}>
|
||||
<span className={styles.title}>{category.name}</span>
|
||||
</div>
|
||||
<div className={styles.contentPanel}>
|
||||
<h2
|
||||
className={styles.panelTitle}
|
||||
onClick={() => {
|
||||
onSelect(category);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</h2>
|
||||
|
||||
{hasChildren && (
|
||||
<button
|
||||
className={styles.expandButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<button
|
||||
className={styles.navigateButton}
|
||||
onClick={handleDirectNavigation}
|
||||
title="Go to category"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className={styles.nestedChildren}>
|
||||
{category.children.map((child) => (
|
||||
<NestedCategory
|
||||
key={child.id}
|
||||
category={child}
|
||||
level={level + 1}
|
||||
handleCategorySelect={handleCategorySelect}
|
||||
closeDropdown={closeDropdown}
|
||||
/>
|
||||
{allColumns.length > 0 && (
|
||||
<div className={styles.columnsGrid}>
|
||||
{allColumns.map((sub) => (
|
||||
<div key={sub.id} className={styles.columnSection}>
|
||||
<div
|
||||
className={styles.sectionTitle}
|
||||
onClick={() => {
|
||||
onSelect(sub);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{sub.name}
|
||||
</div>
|
||||
{sub.children?.map((leaf) => (
|
||||
<span
|
||||
key={leaf.id}
|
||||
className={styles.leafItem}
|
||||
onClick={() => {
|
||||
onSelect(leaf);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{leaf.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -89,113 +68,85 @@ const DropdownMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const {
|
||||
data: categoriesData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetCategoriesQuery("tree");
|
||||
|
||||
const categories = categoriesData?.data || [];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeMainCategory, setActiveMainCategory] = useState(null);
|
||||
const [activeCategory, setActiveCategory] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length > 0) {
|
||||
const defaultCategory =
|
||||
categories.find((cat) => cat.name === "Aýallar üçin") || categories[0];
|
||||
setActiveMainCategory(defaultCategory);
|
||||
if (categories.length > 0 && !activeCategory) {
|
||||
setActiveCategory(categories[0]);
|
||||
}
|
||||
}, [categories]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (categories.length > 0) {
|
||||
const defaultCategory =
|
||||
categories.find((cat) => cat.name === "Aýallar üçin") || categories[0];
|
||||
setActiveMainCategory(defaultCategory);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategorySelect = (category) => {
|
||||
const handleSelect = (category) => {
|
||||
navigate(`/category/${category.id}`, { state: { category } });
|
||||
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 (
|
||||
<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 />
|
||||
{t("navbar.category")}
|
||||
{isLoading
|
||||
? <div className={styles.loadingDots}><span/><span/><span/></div>
|
||||
: t("navbar.category")
|
||||
}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={styles.dropdownWrapper}>
|
||||
<div className={styles.dropdownPanel} onMouseLeave={handleMouseLeave}>
|
||||
<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>
|
||||
<>
|
||||
<div className={styles.overlay} onClick={() => setIsOpen(false)} />
|
||||
|
||||
{activeMainCategory && (
|
||||
<div className={styles.contentPanel}>
|
||||
<h2
|
||||
onClick={() => handleCategorySelect(activeMainCategory)}
|
||||
className={styles.title}
|
||||
>
|
||||
{activeMainCategory.name}
|
||||
</h2>
|
||||
|
||||
<div className={styles.subCategoriesContainer}>
|
||||
{activeMainCategory.children &&
|
||||
activeMainCategory.children.length > 0 ? (
|
||||
activeMainCategory.children.map((subcategory) => (
|
||||
<NestedCategory
|
||||
key={subcategory.id}
|
||||
category={subcategory}
|
||||
handleCategorySelect={handleCategorySelect}
|
||||
closeDropdown={() => setIsOpen(false)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.noSubcategories}>
|
||||
{/* No subcategories available */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.dropdownWrapper}>
|
||||
<div className={styles.dropdownPanel}>
|
||||
<div className={styles.categoriesList}>
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className={`${styles.categoryItem} ${
|
||||
activeCategory?.id === cat.id ? styles.active : ""
|
||||
}`}
|
||||
onMouseEnter={() => setActiveCategory(cat)}
|
||||
onClick={() => handleSelect(cat)}
|
||||
>
|
||||
<span className={styles.title}>{cat.name}</span>
|
||||
{cat.children?.length > 0 && (
|
||||
<ChevronRight size={14} className={styles.chevron} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContentPanel
|
||||
category={activeCategory}
|
||||
onSelect={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import styles from "./Checkout.module.scss";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
usePlaceOrderMutation,
|
||||
@@ -9,202 +8,145 @@ import {
|
||||
} from "../../app/api/orderApi";
|
||||
import { useGetLocationsQuery } from "../../app/api/locationApi";
|
||||
|
||||
const isPriceZero = (price) => !price || parseFloat(price) === 0;
|
||||
|
||||
const useDeviceType = () => {
|
||||
const [deviceType, setDeviceType] = useState("desktop");
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
if (/Mobi|Android/i.test(userAgent)) {
|
||||
setDeviceType("mobile");
|
||||
} else {
|
||||
setDeviceType("desktop");
|
||||
}
|
||||
setDeviceType(
|
||||
/Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "desktop",
|
||||
);
|
||||
}, []);
|
||||
|
||||
return deviceType;
|
||||
};
|
||||
|
||||
const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceOrder }) => {
|
||||
const Checkout = ({
|
||||
cartItems,
|
||||
shippingPrice,
|
||||
productIds,
|
||||
onBackToCart,
|
||||
onPlaceOrder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState({
|
||||
customer_name: "",
|
||||
customer_phone: "",
|
||||
customer_phone: "+993 ",
|
||||
customer_address: "",
|
||||
deliveryAddress: "null",
|
||||
payment_type_id: "",
|
||||
notes: "",
|
||||
region: "",
|
||||
});
|
||||
|
||||
const [selectedAddress, setSelectedAddress] = useState(null);
|
||||
const [placeOrder, { isLoading: isPlacingOrder }] = usePlaceOrderMutation();
|
||||
const { data: orderTimes = {} } = useGetOrderTimesQuery();
|
||||
const [placeOrder] = usePlaceOrderMutation();
|
||||
const { data: orderPayments = [] } = useGetOrderPaymentsQuery();
|
||||
const { data: locationsData } = useGetLocationsQuery();
|
||||
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 { name, value } = e.target;
|
||||
|
||||
|
||||
if (name === "customer_phone") {
|
||||
// Always keep the +993 prefix
|
||||
const prefix = "+993 ";
|
||||
|
||||
// If user is trying to delete the prefix, prevent it
|
||||
if (value.length < prefix.length) {
|
||||
return; // Don't update state, keep the current value
|
||||
}
|
||||
|
||||
// Extract only the digits after the prefix
|
||||
const inputWithoutPrefix = value.substring(prefix.length).replace(/\D/g, "");
|
||||
|
||||
// Limit to 8 digits max (Turkmenistan mobile number format)
|
||||
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,
|
||||
}));
|
||||
if (value.length < prefix.length) return;
|
||||
|
||||
const digits = value
|
||||
.substring(prefix.length)
|
||||
.replace(/\D/g, "")
|
||||
.substring(0, 8);
|
||||
let formatted = prefix + digits.substring(0, 2);
|
||||
if (digits.length > 2) formatted += " " + digits.substring(2);
|
||||
|
||||
setFormData((prev) => ({ ...prev, [name]: formatted }));
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressSelect = (value) => {
|
||||
setSelectedAddress(value);
|
||||
const selectedLocation = locationsData?.data?.find(
|
||||
(location) => location.name === value
|
||||
);
|
||||
|
||||
const selectedLocation = locationsData?.data?.find((l) => l.name === value);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
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 = () => {
|
||||
setSelectedAddress(null);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
address: "",
|
||||
}));
|
||||
setFormData((prev) => ({ ...prev, address: "" }));
|
||||
};
|
||||
|
||||
const handleFocus = (event) => {
|
||||
event.target.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
const handleFocus = (e) =>
|
||||
e.target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
const formatPhoneNumber = (phone) =>
|
||||
phone.replace(/^\+993\s*/, "").replace(/\s+/g, "");
|
||||
|
||||
const getOrderData = () => {
|
||||
// Validation checks
|
||||
if (
|
||||
!formData.customer_name ||
|
||||
!formData.customer_phone ||
|
||||
!formData.customer_address ||
|
||||
!formData.payment_type_id
|
||||
) {
|
||||
console.error("Missing required fields");
|
||||
alert("Please fill in all required fields");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set default values for delivery
|
||||
const currentDate = new Date().toISOString().split('T')[0];
|
||||
const defaultTimeSlot = {
|
||||
date: currentDate,
|
||||
hour: "12:00-14:00" // Default time slot
|
||||
};
|
||||
const currentDate = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Prepare data in the format expected by the API
|
||||
return {
|
||||
customer_name: formData.customer_name,
|
||||
customer_phone: formatPhoneNumber(formData.customer_phone),
|
||||
customer_address: formData.customer_address,
|
||||
shipping_method: "standard", // Default to standard shipping
|
||||
shipping_method: "standard",
|
||||
payment_type_id: formData.payment_type_id,
|
||||
delivery_time: defaultTimeSlot.hour,
|
||||
delivery_at: defaultTimeSlot.date,
|
||||
delivery_time: "12:00-14:00",
|
||||
delivery_at: currentDate,
|
||||
region: formData.region || "",
|
||||
notes: formData.notes || "",
|
||||
// Add shipping price and product IDs
|
||||
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 orderDetails = getOrderData();
|
||||
if (!orderDetails) return false;
|
||||
|
||||
try {
|
||||
const response = await placeOrder(orderDetails).unwrap();
|
||||
|
||||
console.log("Order placed successfully:", response);
|
||||
await placeOrder(orderDetails).unwrap();
|
||||
window.location.href = "/orders";
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to place order:", error);
|
||||
|
||||
if (
|
||||
const isHtmlResponse =
|
||||
error.data &&
|
||||
typeof error.data === "string" &&
|
||||
error.data.includes("<!doctype html>")
|
||||
) {
|
||||
console.error(
|
||||
"Server returned HTML instead of a proper API response"
|
||||
);
|
||||
alert(
|
||||
"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."
|
||||
);
|
||||
}
|
||||
error.data.includes("<!doctype html>");
|
||||
|
||||
alert(
|
||||
isHtmlResponse
|
||||
? "There was a problem with the server. Please try again later."
|
||||
: "Failed to place order. Please check your information and try again.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the function to parent component via callback
|
||||
useEffect(() => {
|
||||
if (onPlaceOrder) {
|
||||
onPlaceOrder(handlePlaceOrder);
|
||||
}
|
||||
if (onPlaceOrder) onPlaceOrder(handlePlaceOrder);
|
||||
}, [formData, shippingPrice, productIds]);
|
||||
|
||||
return (
|
||||
<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.paymentOptions}>
|
||||
<h3>{t("checkout.paymentMethod")}:</h3>
|
||||
@@ -221,22 +163,22 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
<label
|
||||
htmlFor={`payment${payment.id}`}
|
||||
className={styles.customRadio}
|
||||
></label>
|
||||
/>
|
||||
<div
|
||||
className={styles.text}
|
||||
onClick={() => {
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
payment_type_id: String(payment.id),
|
||||
}));
|
||||
}}
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className={styles.optionTitle}>{payment.name}</span>
|
||||
<span className={styles.optionDesc}>
|
||||
{/* <span className={styles.optionDesc}>
|
||||
{payment.name === "Nagt"
|
||||
? t("checkout.payment_in_cash_upon_delivery_of_the_order")
|
||||
: t("checkout.payment_by_card")}
|
||||
</span>
|
||||
</span> */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -256,7 +198,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t("checkout.telephone")}*</label>
|
||||
<input
|
||||
@@ -270,7 +211,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t("checkout.moreAboutYourAddress")}*</label>
|
||||
@@ -283,7 +223,6 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t("checkout.note")}</label>
|
||||
<input
|
||||
@@ -301,22 +240,17 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
<ul>
|
||||
<li>
|
||||
{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>
|
||||
{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>
|
||||
{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"
|
||||
)}
|
||||
</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"
|
||||
"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>
|
||||
</ul>
|
||||
@@ -326,4 +260,4 @@ const Checkout = ({ cartItems, shippingPrice, productIds, onBackToCart, onPlaceO
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkout;
|
||||
export default Checkout;
|
||||
|
||||
206
src/components/FlashSales/FlashSales.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
148
src/components/FlashSales/index.jsx
Normal 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;
|
||||
@@ -94,7 +94,6 @@ const Footer = () => {
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<img src={apk} alt="Download APK" className={styles.appLogo} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
55
src/components/HomeBrands/HomeBrands.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/components/HomeBrands/index.jsx
Normal 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;
|
||||
|
||||
@@ -199,6 +199,7 @@ export const OrderIcon = () => (
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 28.35 28.35"
|
||||
data-v-5c1608dd=""
|
||||
|
||||
>
|
||||
<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"
|
||||
@@ -206,7 +207,22 @@ export const OrderIcon = () => (
|
||||
></path>
|
||||
</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 = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -220,7 +236,7 @@ export const CategoryIcon = () => (
|
||||
height={20}
|
||||
>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
@@ -8,7 +8,7 @@ const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<NavbarDown/>
|
||||
{/* <NavbarDown/> */}
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
align-items: center;
|
||||
border-radius: 0.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 16px;
|
||||
color: #4b5563;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -14,18 +14,35 @@
|
||||
background-color: #fff;
|
||||
margin-bottom: 1px;
|
||||
border-bottom: 3px solid #f3f4f6;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.btn{
|
||||
.btn {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
border: #000000;
|
||||
background-color: #000000;
|
||||
padding: 6px 10px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
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 {
|
||||
@@ -33,7 +50,7 @@
|
||||
background-color: #ffffff;
|
||||
max-width: 1366px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: 80px; // navbarUp yüksekliği kadar
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 1.375rem;
|
||||
@@ -48,11 +65,11 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 10px 22px 0px;
|
||||
height: 60px;
|
||||
height: 80px;
|
||||
gap: 10px;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
@media screen and (max-width: 426px) {
|
||||
@media screen and (max-width: 500px) {
|
||||
height: 40px;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 15px 6px;
|
||||
@@ -66,14 +83,19 @@
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
@media screen and (max-width: 426px) {
|
||||
width: 80px;
|
||||
img {
|
||||
width: 300px;
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
}
|
||||
}
|
||||
.stick {
|
||||
@@ -85,6 +107,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navLinks {
|
||||
width: 100%;
|
||||
}
|
||||
.navLinks ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
@@ -106,8 +131,8 @@
|
||||
margin: 0;
|
||||
svg {
|
||||
fill: #4b5563;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
.searchWrapper {
|
||||
@@ -115,7 +140,6 @@
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
@@ -154,7 +178,7 @@
|
||||
align-items: center;
|
||||
border-radius: 0.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 16px;
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
background-color: transparent;
|
||||
@@ -162,6 +186,10 @@
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.cartSection {
|
||||
@@ -192,7 +220,7 @@
|
||||
@media screen and (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
@media screen and (max-width: 426px) {
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 9px 0;
|
||||
}
|
||||
}
|
||||
@@ -250,9 +278,23 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { UserOutlined, LogoutOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import { UserOutlined, LogoutOutlined, HomeOutlined, ShopOutlined } from "@ant-design/icons";
|
||||
import { FaGlobe } from "react-icons/fa6";
|
||||
import { Input, Badge, Menu, Dropdown } from "antd";
|
||||
const { Search } = Input;
|
||||
@@ -135,7 +135,7 @@ const NavbarDown = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<header className={styles.navbar}>
|
||||
<header className={styles.navbar} style={{ width: "100%" }}>
|
||||
<div className={styles.navbarDown} style={{ position: "sticky" }}>
|
||||
<nav className={styles.navLinks}>
|
||||
<ul>
|
||||
@@ -151,6 +151,15 @@ const NavbarDown = () => {
|
||||
</button>
|
||||
</Link>
|
||||
</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}>
|
||||
<CiSearch />
|
||||
<input
|
||||
@@ -214,7 +223,7 @@ const NavbarDown = () => {
|
||||
count={ordersItemCount}
|
||||
offset={[10, 0]}
|
||||
>
|
||||
<button className={styles.navButton}>
|
||||
<button className={styles.navButton} >
|
||||
<OrderIcon />
|
||||
</button>
|
||||
</Badge>
|
||||
@@ -255,7 +264,10 @@ const NavbarDown = () => {
|
||||
</div>
|
||||
<div className={styles.stick}></div>
|
||||
<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 className={styles.stick}></div>
|
||||
<div className={styles.searchIcon} onClick={toggleSearch}>
|
||||
|
||||
@@ -4,40 +4,101 @@ import SignupForm from "../BeSeller/index";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const showModal = () => {
|
||||
setIsModalVisible(true);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const changeLanguage = (langCode) => {
|
||||
i18n.changeLanguage(langCode);
|
||||
localStorage.setItem("preferredLanguage", langCode);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
const languages = [
|
||||
{ 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 (
|
||||
<>
|
||||
<header className={styles.navbar}>
|
||||
<div className={styles.navbarUp}>
|
||||
<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.logoContainer}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{/* <LogoWithText /> */}
|
||||
<img style={{width: "200px"}} src={Logo} alt="" />
|
||||
<img src={Logo} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "8px 14px 6px" }}>
|
||||
<button className={styles.btn} onClick={showModal}>
|
||||
Satyjy bol
|
||||
</button>
|
||||
<div className={styles.langSelector}>
|
||||
{languages.map((lang) => (
|
||||
<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>
|
||||
<NavbarDown />
|
||||
</header>
|
||||
<Modal
|
||||
open={isModalVisible}
|
||||
|
||||
102
src/components/PendingPriceBadge/PendingPriceBadge.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/components/PendingPriceBadge/index.jsx
Normal 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;
|
||||
@@ -5,14 +5,14 @@
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media screen and (max-width: 426px) {
|
||||
@@ -30,6 +30,7 @@
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
@media screen and (max-width: 426px) {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -71,8 +72,16 @@
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
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) {
|
||||
font-size: 14px;
|
||||
height: 2.8em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +90,15 @@
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
height: 2.8em;
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
font-size: 12px;
|
||||
height: 2.8em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +106,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin: 0;
|
||||
margin-top: auto;
|
||||
margin-bottom: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.productImage {
|
||||
width: 99%;
|
||||
height: auto;
|
||||
@@ -31,6 +31,11 @@
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hovered {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Style for images inside detail view */
|
||||
@@ -145,6 +150,7 @@
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
||||
@@ -6,6 +6,7 @@ const ImageCarousel = ({
|
||||
altText,
|
||||
showThumbnails = false,
|
||||
isDetailView = false,
|
||||
isHovered = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -28,15 +29,15 @@ const ImageCarousel = ({
|
||||
: images[0]?.images_1200x1200 || "";
|
||||
|
||||
// Auto-slide functionality
|
||||
useEffect(() => {
|
||||
if (!hasMultipleImages || isModalOpen) return;
|
||||
// useEffect(() => {
|
||||
// if (!hasMultipleImages || isModalOpen) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
}, 9000);
|
||||
// const interval = setInterval(() => {
|
||||
// setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
// }, 9000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasMultipleImages, images, isModalOpen]);
|
||||
// return () => clearInterval(interval);
|
||||
// }, [hasMultipleImages, images, isModalOpen]);
|
||||
|
||||
// Reset zoom/rotation when modal closes or image changes
|
||||
useEffect(() => {
|
||||
@@ -220,7 +221,8 @@ const ImageCarousel = ({
|
||||
isDetailView ? styles.detailImage : styles.cardImage
|
||||
}`}
|
||||
onClick={isDetailView ? openModal : undefined}
|
||||
style={{ cursor: isDetailView ? "pointer" : "default" }}
|
||||
style={{ cursor: isDetailView ? "pointer" : "default" , transform: isHovered ? "scale(1.05)" : "none" }}
|
||||
|
||||
/>
|
||||
{isDetailView && renderModal()}
|
||||
</div>
|
||||
@@ -450,7 +452,7 @@ const ImageCarousel = ({
|
||||
alt={altText || "Product image"}
|
||||
className={`${styles.productImage} ${
|
||||
isDetailView ? styles.detailImage : styles.cardImage
|
||||
}`}
|
||||
} ${isHovered ? styles.hovered : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,36 +3,32 @@ import styles from "./ProductCard.module.scss";
|
||||
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
|
||||
import { FaShoppingCart } from "react-icons/fa";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
useAddFavoriteMutation,
|
||||
useRemoveFavoriteMutation,
|
||||
useGetFavoritesQuery,
|
||||
} from "../../app/api/favoritesApi";
|
||||
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
|
||||
import {
|
||||
useAddToCartMutation,
|
||||
useUpdateCartItemMutation,
|
||||
useRemoveFromCartMutation,
|
||||
useGetCartQuery,
|
||||
} from "../../app/api/cartApi";
|
||||
import { Modal } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DecreaseIcon, IncreaseIcon } from "../Icons";
|
||||
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 tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = htmlString;
|
||||
const textContent = tempDiv.textContent || tempDiv.innerText || "";
|
||||
const truncatedText =
|
||||
textContent.length > maxLength
|
||||
? textContent.substring(0, maxLength).trim() + "..."
|
||||
: textContent;
|
||||
return truncatedText;
|
||||
return textContent.length > maxLength
|
||||
? textContent.substring(0, maxLength).trim() + "..."
|
||||
: textContent;
|
||||
};
|
||||
|
||||
import { useCart } from "../../app/api/useCart";
|
||||
const isPriceZero = (price) => !price || parseFloat(price) === 0;
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
@@ -41,26 +37,24 @@ const ProductCard = ({
|
||||
onAddToCart,
|
||||
onToggleFavorite,
|
||||
isFavorite = false,
|
||||
descriptionMaxLength = 85,
|
||||
descriptionMaxLength = 120,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const [addFavorite] = useAddFavoriteMutation();
|
||||
const [removeFavorite] = useRemoveFavoriteMutation();
|
||||
const { data: favoriteProducts = [] } = useGetFavoritesQuery();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [localIsFavorite, setLocalIsFavorite] = useState(
|
||||
favoriteProducts.some((fav) => fav.product?.id === product.id),
|
||||
);
|
||||
|
||||
const truncatedDesc = truncateDescription(
|
||||
product.description,
|
||||
descriptionMaxLength,
|
||||
const [localIsFavorite, setLocalIsFavorite] = useState(
|
||||
favoriteProducts.some((fav) => fav.product?.id === product.id)
|
||||
);
|
||||
|
||||
const { getCartItem } = useCart();
|
||||
|
||||
const [addToCart] = useAddToCartMutation();
|
||||
const [updateCartItem] = useUpdateCartItemMutation();
|
||||
const [removeFromCart] = useRemoveFromCartMutation();
|
||||
@@ -69,26 +63,54 @@ const ProductCard = ({
|
||||
const [localQuantity, setLocalQuantity] = 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(() => {
|
||||
const qty = parseInt(
|
||||
cartItem?.quantity || cartItem?.product_quantity || 0,
|
||||
10,
|
||||
);
|
||||
const qty = parseInt(cartItem?.quantity || cartItem?.product_quantity || 0, 10);
|
||||
setLocalQuantity(qty);
|
||||
setPendingQuantity(qty);
|
||||
}, [cartItem]);
|
||||
|
||||
// ✅ Favorite state'i güncelle
|
||||
useEffect(() => {
|
||||
if (Array.isArray(favoriteProducts)) {
|
||||
const isFav = favoriteProducts.some(
|
||||
(fav) => fav.product?.id === product.id,
|
||||
setLocalIsFavorite(
|
||||
favoriteProducts.some((fav) => fav.product?.id === product.id)
|
||||
);
|
||||
setLocalIsFavorite(isFav);
|
||||
}
|
||||
}, [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) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -98,51 +120,17 @@ const ProductCard = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Optimistic update
|
||||
setLocalQuantity((prev) => prev + 1);
|
||||
setPendingQuantity((prev) => prev + 1);
|
||||
|
||||
try {
|
||||
await addToCart({ productId: product.id, quantity: 1 }).unwrap();
|
||||
// ✅ Başarılı - RTK Query otomatik cache'i güncelleyecek
|
||||
} catch (error) {
|
||||
console.error("Failed to add to cart:", error);
|
||||
// ✅ Hata varsa geri al
|
||||
} catch {
|
||||
setLocalQuantity((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) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -165,24 +153,17 @@ const ProductCard = ({
|
||||
if (isLoading) return;
|
||||
|
||||
if (pendingQuantity <= 1) {
|
||||
// ✅ Sıfıra düşünce direkt sil
|
||||
setPendingQuantity(0);
|
||||
setLocalQuantity(0);
|
||||
setIsLoading(true);
|
||||
|
||||
removeFromCart({ productId: product.id })
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
// ✅ Başarılı - RTK Query cache'i güncelleyecek
|
||||
})
|
||||
.catch(() => {
|
||||
// ✅ Hata varsa geri al
|
||||
setLocalQuantity(1);
|
||||
setPendingQuantity(1);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setLocalQuantity((prev) => prev - 1);
|
||||
setPendingQuantity((prev) => prev - 1);
|
||||
@@ -196,61 +177,63 @@ const ProductCard = ({
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// ✅ Optimistic update
|
||||
setLocalIsFavorite(!localIsFavorite);
|
||||
setLocalIsFavorite((prev) => !prev);
|
||||
|
||||
try {
|
||||
if (localIsFavorite) {
|
||||
const result = await removeFavorite(product.id).unwrap();
|
||||
// ✅ Başarılı - RTK Query otomatik güncelleyecek
|
||||
await removeFavorite(product.id).unwrap();
|
||||
} else {
|
||||
const result = await addFavorite(product.id).unwrap();
|
||||
// ✅ Başarılı - RTK Query otomatik güncelleyecek
|
||||
await addFavorite(product.id).unwrap();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle favorite:", error);
|
||||
// ✅ Hata varsa geri al
|
||||
setLocalIsFavorite(localIsFavorite);
|
||||
} catch {
|
||||
setLocalIsFavorite((prev) => !prev); // revert
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/product/${product.id}`);
|
||||
};
|
||||
|
||||
const { name, price_amount, old_price_amount, media = [], reviews } = product;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.productCard} onClick={handleCardClick}>
|
||||
<div
|
||||
className={styles.productCard}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={styles.imageContainer}>
|
||||
{product.discount && (
|
||||
<span className={styles.discountBadge}>-{product.discount}%</span>
|
||||
{(product.discount > 0 || calculatedDiscount > 0) && (
|
||||
<span className={styles.discountBadge}>
|
||||
-{product.discount || calculatedDiscount}%
|
||||
</span>
|
||||
)}
|
||||
{product.stock === 0 && (
|
||||
<span className={`${styles.discountBadge} ${styles.outOfStock}`}>
|
||||
{t("common.out_of_stock")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ImageCarousel images={media} altText={name} />
|
||||
<ImageCarousel images={media} altText={name} isHovered={isHovered} />
|
||||
</div>
|
||||
|
||||
<div className={styles.productInfo}>
|
||||
<h3 className={styles.productName}>{name}</h3>
|
||||
<p className={styles.productDescription}>{truncatedDesc}</p>
|
||||
|
||||
<div className={styles.priceContainer}>
|
||||
<div>
|
||||
<span className={styles.currentPrice}>{price_amount} m.</span>
|
||||
{old_price_amount && (
|
||||
<span className={styles.oldPrice}>{old_price_amount} m.</span>
|
||||
{isPriceZero(price_amount) ? (
|
||||
<span className={styles.currentPrice}> {t("cart.pendingPriceTitle")}</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 className={styles.actions}>
|
||||
{showFavoriteButton && (
|
||||
<button
|
||||
@@ -261,6 +244,7 @@ const ProductCard = ({
|
||||
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAddToCart && (
|
||||
<>
|
||||
{localQuantity > 0 ? (
|
||||
@@ -322,4 +306,4 @@ const ProductCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
export default ProductCard;
|
||||
@@ -12,24 +12,23 @@ import {
|
||||
Info,
|
||||
Edit,
|
||||
MapPin,
|
||||
Store,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styles from "./ProfileMenu.module.scss";
|
||||
import LoginModal from "../LogIn";
|
||||
import SignUpModal from "../SignUp";
|
||||
import ProfileModal from "..//MyProfileModal/index";
|
||||
import ProfileModal from "..//MyProfileModal/index";
|
||||
import tm from "../../assets/tm.png";
|
||||
import ru from "../../assets/ru.png";
|
||||
import en from "../../assets/en.png";
|
||||
import { useAuth } from "../../context/authContext";
|
||||
import { useGetProfileQuery } from "../../app/api/myProfileApi";
|
||||
|
||||
import { useGetProfileQuery } from "../../app/api/myProfileApi";
|
||||
const ProfileMenu = () => {
|
||||
const [activeModal, setActiveModal] = useState(null);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
|
||||
// Fetch profile data from API
|
||||
const { data: profileData, isLoading } = useGetProfileQuery(undefined, {
|
||||
skip: !isAuthenticated, // Skip the API call if not authenticated
|
||||
@@ -55,6 +54,11 @@ const ProfileMenu = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action === "/panel") {
|
||||
window.location.href = "/panel";
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action) {
|
||||
setActiveModal(item.action);
|
||||
}
|
||||
@@ -62,7 +66,7 @@ const ProfileMenu = () => {
|
||||
|
||||
const handleLanguageChange = async (langCode) => {
|
||||
await i18n.changeLanguage(langCode);
|
||||
localStorage.setItem("preferredLanguage", langCode);
|
||||
localStorage.setItem("preferredLanguage", langCode);
|
||||
setActiveModal(null);
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -84,6 +88,7 @@ const ProfileMenu = () => {
|
||||
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
|
||||
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
|
||||
{ icon: <Languages />, text: t("profile.language"), action: "language" },
|
||||
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
|
||||
{
|
||||
icon: <List />,
|
||||
text: t("profile.delivery"),
|
||||
@@ -102,7 +107,9 @@ const ProfileMenu = () => {
|
||||
<User className={styles.userIcon} />
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.phoneNumber}>+993 {userData.phone_number}</div>
|
||||
<div className={styles.phoneNumber}>
|
||||
+993 {userData.phone_number}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEditProfile}
|
||||
className={styles.editProfileLink}
|
||||
@@ -192,6 +199,7 @@ const ProfileMenu = () => {
|
||||
{ icon: <Wallet />, text: t("profile.orders"), path: "/orders" },
|
||||
{ icon: <Heart />, text: t("profile.favorites"), path: "/wishlist" },
|
||||
{ icon: <Languages />, text: t("profile.language"), action: "language" },
|
||||
{ icon: <Store />, text: t("profile.seller_panel"), action: "/panel" },
|
||||
{
|
||||
icon: <List />,
|
||||
text: t("profile.delivery"),
|
||||
|
||||
241
src/components/StoryViewer/StoryViewer.jsx
Normal 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;
|
||||
245
src/components/StoryViewer/StoryViewer.module.scss
Normal 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); }
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
export default {
|
||||
navbar: {
|
||||
category: "Categories",
|
||||
login: "Login",
|
||||
signUp: "Sign Up",
|
||||
brands: "Brands",
|
||||
stores: "Stores",
|
||||
search: "Search by product name...",
|
||||
cart: "Cart",
|
||||
home: "Home",
|
||||
@@ -12,6 +14,14 @@ export default {
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
},
|
||||
},
|
||||
flashSales: {
|
||||
flash_sale: "FLASH SALE",
|
||||
ends_in: "Ends in:",
|
||||
day: "day",
|
||||
hour: "hr",
|
||||
minute: "min",
|
||||
second: "sec",
|
||||
},
|
||||
cart: {
|
||||
basket: "Basket",
|
||||
@@ -29,6 +39,9 @@ export default {
|
||||
emptyCartTitle: "Your cart is empty",
|
||||
emptyCartMessage: "Looks like you haven't added any items to your cart yet",
|
||||
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: {
|
||||
paymentMethod: "Payment Method",
|
||||
@@ -129,6 +142,8 @@ export default {
|
||||
verify: "Verify",
|
||||
name: "Name",
|
||||
address: "Address",
|
||||
seller_panel: "Seller Panel",
|
||||
|
||||
},
|
||||
order: {
|
||||
orderDate: "Order Date",
|
||||
@@ -172,11 +187,25 @@ export default {
|
||||
price: "Price",
|
||||
minPrice: "Min 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: {
|
||||
productCode: "Product code",
|
||||
barCode: "Barcode",
|
||||
similarProducts: "Similar Products",
|
||||
description: "Product description",
|
||||
price: "Price",
|
||||
readMore: "Read more...",
|
||||
readLess: "Show less",
|
||||
},
|
||||
wishtList: {
|
||||
likedProducts: "Favorites",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
export default {
|
||||
navbar: {
|
||||
category: "Категории",
|
||||
login: "Войти",
|
||||
signUp: "Регистрация",
|
||||
brands: "Бренды",
|
||||
stores: "Магазины",
|
||||
search: "Поиск по названию товара...",
|
||||
cart: "Корзина",
|
||||
home: "Главная",
|
||||
@@ -12,6 +14,14 @@ export default {
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
},
|
||||
},
|
||||
flashSales: {
|
||||
flash_sale: "ФЛЭШ-РАСПРОДАЖА",
|
||||
ends_in: "До конца:",
|
||||
day: "дн.",
|
||||
hour: "ч.",
|
||||
minute: "мин.",
|
||||
second: "сек.",
|
||||
},
|
||||
cart: {
|
||||
basket: "Корзина",
|
||||
@@ -29,6 +39,9 @@ export default {
|
||||
emptyCartTitle: "Ваша корзина пуста",
|
||||
emptyCartMessage: "Похоже, вы еще не добавили ни одного товара в корзину",
|
||||
continueShopping: "Продолжить покупки",
|
||||
pendingPriceTitle: "Цена уточняется",
|
||||
pendingPriceDesc: "Цена на один или несколько товаров в этом заказе еще не определена. Наш оператор свяжется с вами для предоставления дополнительной информации.",
|
||||
pendingPriceTooltipDesc: "Цена на этот товар в заказе не определена. Оператор позвонит вам и предоставит дополнительную информацию."
|
||||
},
|
||||
checkout: {
|
||||
paymentMethod: "Способ оплаты",
|
||||
@@ -126,6 +139,7 @@ export default {
|
||||
name: "Имя",
|
||||
address: "Address",
|
||||
lastname: "Фамилия",
|
||||
seller_panel: "Панель продавца",
|
||||
},
|
||||
order: {
|
||||
orderDate: "Дата заказа",
|
||||
@@ -167,13 +181,27 @@ export default {
|
||||
From_expensive_to_cheap: "От дорогих к дешевым",
|
||||
From_cheap_to_expensive: "От дешевых к дорогим",
|
||||
price: "Цена",
|
||||
minPrice: "Минимальная цена",
|
||||
maxPrice: "Максимальная цена",
|
||||
},
|
||||
minPrice: "Мин цена",
|
||||
maxPrice: "Макс цена",
|
||||
priceHighToLow: "От дорогих к дешевым",
|
||||
priceLowToHigh: "От дешевых к дорогим",
|
||||
priceRange: "Диапазон цен",
|
||||
under50: "До 50m",
|
||||
under100: "До 100m",
|
||||
from50to200: "50 - 200",
|
||||
from200to500: "200 - 500",
|
||||
from500to1000: "500 - 1000",
|
||||
over1000: "Более 1000m",
|
||||
sortBy: "Сортировать по",
|
||||
},
|
||||
product: {
|
||||
productCode: "Код товара",
|
||||
barCode: "Штрих-код",
|
||||
similarProducts: "Похожие товары",
|
||||
description: "Описание товара",
|
||||
price: "Цена",
|
||||
readMore: "Читать далее...",
|
||||
readLess: "Свернуть",
|
||||
},
|
||||
wishtList: {
|
||||
likedProducts: "Избранные",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
export default {
|
||||
navbar: {
|
||||
category: "Kategoriýalar",
|
||||
login: "Giriş",
|
||||
signUp: "Agza bolmak",
|
||||
brands: "Brendler",
|
||||
stores: "Dükanlar",
|
||||
search: "Haryt ady boýunça gözleg...",
|
||||
cart: "Sebet",
|
||||
home: "Baş sahypa",
|
||||
@@ -12,6 +14,14 @@ export default {
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
},
|
||||
},
|
||||
flashSales: {
|
||||
flash_sale: "GYSGA WAGTLYK ARZANLADYŞ",
|
||||
ends_in: "Gutarýança:",
|
||||
day: "gün",
|
||||
hour: "sag",
|
||||
minute: "min",
|
||||
second: "sek",
|
||||
},
|
||||
cart: {
|
||||
basket: "Sebet",
|
||||
@@ -29,6 +39,9 @@ export default {
|
||||
emptyCartTitle: "Sebediňiz boş",
|
||||
emptyCartMessage: "Sebediňize entek hiç zat goşmadyňyz.",
|
||||
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: {
|
||||
paymentMethod: "Töleg görnüşi",
|
||||
@@ -129,6 +142,7 @@ export default {
|
||||
name: "Ady",
|
||||
address: "Salgy",
|
||||
lastname: "Familýaňyz",
|
||||
seller_panel: "Satyjy paneli",
|
||||
},
|
||||
order: {
|
||||
orderDate: "Sargyt senesi",
|
||||
@@ -170,13 +184,27 @@ export default {
|
||||
From_expensive_to_cheap: "Gymmatdan arzana",
|
||||
From_cheap_to_expensive: "Arzandan gymmada",
|
||||
price: "Bahasy",
|
||||
maxPrice: "Maksimum baha",
|
||||
minPrice: "Minimum baha",
|
||||
},
|
||||
maxPrice: "Maks 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: {
|
||||
productCode: "Haryt kody",
|
||||
barCode: "Çyzgyç kod",
|
||||
similarProducts: "Meňzeş harytlar",
|
||||
description: "Haryt barada düşündiriş",
|
||||
price: "Bahasy",
|
||||
readMore: "Giňişleýin oka...",
|
||||
readLess: "Gysgaltmak",
|
||||
},
|
||||
wishtList: {
|
||||
likedProducts: "Halanlarym",
|
||||
|
||||
651
src/pages/CarconfiguratorAdmin/Adminpage.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
435
src/pages/CarconfiguratorAdmin/index.jsx
Normal 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)} />;
|
||||
}
|
||||
@@ -16,10 +16,9 @@
|
||||
.cartHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
justify-content: space-between;
|
||||
background-color: #f3f4f6;
|
||||
padding-bottom: 15px;
|
||||
padding-top: 10px;
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
@@ -27,11 +26,11 @@
|
||||
@media screen and (max-width: 768px) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cartProducts {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -152,6 +151,7 @@
|
||||
@media screen and (max-width: 720px) {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.price {
|
||||
@@ -226,7 +226,7 @@
|
||||
@media screen and (max-width: 1023px) {
|
||||
width: 100%;
|
||||
position: static;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import styles from "./CartPage.module.scss";
|
||||
import { FaTrashAlt } from "react-icons/fa";
|
||||
import Checkout from "../../components/Checkout";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Modal } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmptyCartState from "./emptyCart";
|
||||
import {
|
||||
useGetCartQuery,
|
||||
useAddToCartMutation,
|
||||
useRemoveFromCartMutation,
|
||||
useUpdateCartItemMutation,
|
||||
useCleanCartMutation,
|
||||
@@ -16,10 +13,11 @@ import {
|
||||
import { useCart } from "../../app/api/useCart";
|
||||
import { DecreaseIcon, IncreaseIcon } from "../../components/Icons";
|
||||
import Loader from "../../components/Loader/index";
|
||||
import PendingPriceBadge from "../../components/PendingPriceBadge";
|
||||
|
||||
const isPriceZero = (price) => !price || parseFloat(price) === 0;
|
||||
|
||||
const TruncatedDescription = ({ description, maxLength = 100 }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const stripHtml = (html) => {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.body.textContent || "";
|
||||
@@ -32,11 +30,9 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
|
||||
<div className={styles.truncatedDescription}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: isExpanded
|
||||
? description
|
||||
: shouldTruncate
|
||||
? description.substring(0, maxLength) + "..."
|
||||
: description,
|
||||
__html: shouldTruncate
|
||||
? description.substring(0, maxLength) + "..."
|
||||
: description,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -44,20 +40,16 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
|
||||
};
|
||||
|
||||
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 [addToCart] = useAddToCartMutation();
|
||||
const [removeFromCart] = useRemoveFromCartMutation();
|
||||
const [updateCartItem] = useUpdateCartItemMutation();
|
||||
const [cleanCart] = useCleanCartMutation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const expandedRef = useRef(null);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [emptyCartModalVisible, setEmptyCartModalVisible] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
|
||||
const [localQuantities, setLocalQuantities] = useState({});
|
||||
const [pendingQuantities, setPendingQuantities] = useState({});
|
||||
const [loadingItems, setLoadingItems] = useState({});
|
||||
@@ -70,43 +62,35 @@ const CartPage = () => {
|
||||
width: 400,
|
||||
};
|
||||
|
||||
// Convert grouped data to stores array
|
||||
const stores = useMemo(() => {
|
||||
return Object.entries(cartData)
|
||||
.map(([storeSlug, items]) => {
|
||||
if (!items || !items.length) return null;
|
||||
|
||||
// Get store info from first item
|
||||
if (!items?.length) return null;
|
||||
const storeInfo = items[0]?.product?.channel?.[0];
|
||||
|
||||
return {
|
||||
id: storeInfo?.id || storeSlug,
|
||||
name: storeInfo?.name || storeSlug,
|
||||
slug: storeSlug,
|
||||
shipping_price: storeInfo?.shipping_price,
|
||||
items: items,
|
||||
items,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [cartData]);
|
||||
|
||||
// ✅ Initialize local quantities from cart items
|
||||
useEffect(() => {
|
||||
const newLocalQuantities = {};
|
||||
const newPendingQuantities = {};
|
||||
|
||||
const newLocal = {};
|
||||
const newPending = {};
|
||||
cartItems.forEach((item) => {
|
||||
const productId = item.product.id;
|
||||
const quantity = parseInt(item.product_quantity, 10) || 0;
|
||||
newLocalQuantities[productId] = quantity;
|
||||
newPendingQuantities[productId] = quantity;
|
||||
const id = item.product.id;
|
||||
const qty = parseInt(item.product_quantity, 10) || 0;
|
||||
newLocal[id] = qty;
|
||||
newPending[id] = qty;
|
||||
});
|
||||
|
||||
setLocalQuantities(newLocalQuantities);
|
||||
setPendingQuantities(newPendingQuantities);
|
||||
setLocalQuantities(newLocal);
|
||||
setPendingQuantities(newPending);
|
||||
}, [cartItems]);
|
||||
|
||||
// ✅ Debounced Cart Update - Her ürün için ayrı debounce
|
||||
useEffect(() => {
|
||||
const timers = {};
|
||||
|
||||
@@ -114,141 +98,94 @@ const CartPage = () => {
|
||||
const serverItem = cartItems.find(
|
||||
(item) => String(item.product.id) === String(productId),
|
||||
);
|
||||
const serverQuantity = serverItem
|
||||
const serverQty = serverItem
|
||||
? parseInt(serverItem.product_quantity, 10)
|
||||
: 0;
|
||||
const pendingQuantity = pendingQuantities[productId];
|
||||
const pendingQty = pendingQuantities[productId];
|
||||
|
||||
// Değişiklik yoksa veya 0 ise (Delete modalı tetikler) bir şey yapma
|
||||
if (
|
||||
pendingQuantity === undefined ||
|
||||
pendingQuantity === serverQuantity ||
|
||||
pendingQuantity <= 0
|
||||
) {
|
||||
pendingQty === undefined ||
|
||||
pendingQty === serverQty ||
|
||||
pendingQty <= 0
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
timers[productId] = setTimeout(async () => {
|
||||
try {
|
||||
setLoadingItems((prev) => ({ ...prev, [productId]: true }));
|
||||
await updateCartItem({
|
||||
productId,
|
||||
quantity: pendingQuantity,
|
||||
}).unwrap();
|
||||
} catch (error) {
|
||||
console.error("Failed to update cart:", error);
|
||||
// Hata durumunda rollback
|
||||
setLocalQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: serverQuantity,
|
||||
}));
|
||||
setPendingQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: serverQuantity,
|
||||
}));
|
||||
await updateCartItem({ productId, quantity: pendingQty }).unwrap();
|
||||
} catch {
|
||||
setLocalQuantities((prev) => ({ ...prev, [productId]: serverQty }));
|
||||
setPendingQuantities((prev) => ({ ...prev, [productId]: serverQty }));
|
||||
} finally {
|
||||
setLoadingItems((prev) => ({ ...prev, [productId]: false }));
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.values(timers).forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
return () => Object.values(timers).forEach(clearTimeout);
|
||||
}, [pendingQuantities, cartItems, updateCartItem]);
|
||||
|
||||
const handleQuantityIncrease = (productId) => (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (loadingItems[productId]) return;
|
||||
|
||||
const item = cartItems.find((item) => item.product.id === productId);
|
||||
if (!item) return;
|
||||
const item = cartItems.find((i) => i.product.id === productId);
|
||||
if (!item || localQuantities[productId] >= item.product.stock) return;
|
||||
|
||||
if (localQuantities[productId] >= item.product.stock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuantity = (localQuantities[productId] || 0) + 1;
|
||||
setLocalQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: newQuantity,
|
||||
}));
|
||||
setPendingQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: newQuantity,
|
||||
}));
|
||||
const newQty = (localQuantities[productId] || 0) + 1;
|
||||
setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
|
||||
setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
|
||||
};
|
||||
|
||||
const handleQuantityDecrease = (productId) => (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (loadingItems[productId]) return;
|
||||
|
||||
const currentQuantity = localQuantities[productId] || 0;
|
||||
|
||||
if (currentQuantity <= 1) {
|
||||
const currentQty = localQuantities[productId] || 0;
|
||||
if (currentQty <= 1) {
|
||||
showDeleteConfirm(productId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuantity = currentQuantity - 1;
|
||||
setLocalQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: newQuantity,
|
||||
}));
|
||||
setPendingQuantities((prev) => ({
|
||||
...prev,
|
||||
[productId]: newQuantity,
|
||||
}));
|
||||
const newQty = currentQty - 1;
|
||||
setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
|
||||
setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
|
||||
};
|
||||
|
||||
const calculateStoreTotal = (storeItems) => {
|
||||
return storeItems.reduce((sum, item) => {
|
||||
const itemPrice = parseFloat(item.product.price_amount) || 0;
|
||||
const itemQuantity = parseInt(item.product_quantity, 10) || 0;
|
||||
return sum + itemPrice * itemQuantity;
|
||||
const getStoreShippingPrice = (store) =>
|
||||
store.shipping_price != null ? parseFloat(store.shipping_price) : 20;
|
||||
|
||||
// Store içinde fiyatsız ürün var mı?
|
||||
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);
|
||||
};
|
||||
|
||||
const getStoreShippingPrice = (store) => {
|
||||
return store.shipping_price !== null && store.shipping_price !== undefined
|
||||
? parseFloat(store.shipping_price)
|
||||
: 20;
|
||||
};
|
||||
|
||||
const handleCheckout = (storeId) => {
|
||||
const handleCheckout = (storeId) =>
|
||||
setCheckoutStores((prev) => ({ ...prev, [storeId]: true }));
|
||||
};
|
||||
|
||||
const handleBackToCart = (storeId) => {
|
||||
const handleBackToCart = (storeId) =>
|
||||
setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
|
||||
};
|
||||
|
||||
const handleOrderSubmit = async (storeId, storeItems) => {
|
||||
const handleOrderSubmit = async (storeId) => {
|
||||
if (checkoutStores[storeId] && checkoutRefs.current[storeId]) {
|
||||
const success = await checkoutRefs.current[storeId]();
|
||||
if (success) {
|
||||
setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
|
||||
}
|
||||
if (success) setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
|
||||
} else {
|
||||
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) => {
|
||||
setItemToDelete(productId);
|
||||
setDeleteModalVisible(true);
|
||||
@@ -258,48 +195,41 @@ const CartPage = () => {
|
||||
if (itemToDelete) {
|
||||
try {
|
||||
await removeFromCart({ productId: itemToDelete }).unwrap();
|
||||
|
||||
setLocalQuantities((prev) => {
|
||||
const newState = { ...prev };
|
||||
delete newState[itemToDelete];
|
||||
return newState;
|
||||
const s = { ...prev };
|
||||
delete s[itemToDelete];
|
||||
return s;
|
||||
});
|
||||
setPendingQuantities((prev) => {
|
||||
const newState = { ...prev };
|
||||
delete newState[itemToDelete];
|
||||
return newState;
|
||||
const s = { ...prev };
|
||||
delete s[itemToDelete];
|
||||
return s;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remove item:", error);
|
||||
} catch (e) {
|
||||
console.error("Failed to remove item:", e);
|
||||
}
|
||||
}
|
||||
setDeleteModalVisible(false);
|
||||
setItemToDelete(null);
|
||||
};
|
||||
|
||||
const showEmptyCartConfirm = () => {
|
||||
setEmptyCartModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEmptyCartConfirm = async () => {
|
||||
try {
|
||||
await cleanCart().unwrap();
|
||||
|
||||
setLocalQuantities({});
|
||||
setPendingQuantities({});
|
||||
setCheckoutStores({});
|
||||
} catch (error) {
|
||||
console.error("Failed to clean cart:", error);
|
||||
} catch (e) {
|
||||
console.error("Failed to clean cart:", e);
|
||||
}
|
||||
setEmptyCartModalVisible(false);
|
||||
};
|
||||
|
||||
const getTotalItemCount = () => {
|
||||
return cartItems.reduce(
|
||||
const getTotalItemCount = () =>
|
||||
cartItems.reduce(
|
||||
(sum, item) => sum + parseInt(item.product_quantity, 10),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.cartContainer}>
|
||||
@@ -339,21 +269,20 @@ const CartPage = () => {
|
||||
<h2>
|
||||
{t("cart.basket")} ({getTotalItemCount()})
|
||||
</h2>
|
||||
<div>
|
||||
<button
|
||||
className={styles.deleteBtn}
|
||||
style={{ padding: "4px 12px" }}
|
||||
onClick={showEmptyCartConfirm}
|
||||
>
|
||||
<FaTrashAlt /> {t("cart.clearCart")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={styles.deleteBtn}
|
||||
style={{ padding: "4px 12px" }}
|
||||
onClick={() => setEmptyCartModalVisible(true)}
|
||||
>
|
||||
<FaTrashAlt /> {t("cart.clearCart")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stores.map((store) => {
|
||||
const shippingPrice = getStoreShippingPrice(store);
|
||||
const storeTotal = calculateStoreTotal(store.items);
|
||||
const totalWithShipping = storeTotal + shippingPrice;
|
||||
const hasZeroPrice = storeHasZeroPriceItem(store.items);
|
||||
|
||||
return (
|
||||
<div key={store.id} className={styles.storeSection}>
|
||||
@@ -363,8 +292,8 @@ const CartPage = () => {
|
||||
shippingPrice={shippingPrice}
|
||||
productIds={store.items.map((item) => item.product.id)}
|
||||
onBackToCart={() => handleBackToCart(store.id)}
|
||||
onPlaceOrder={(placeOrderFn) => {
|
||||
checkoutRefs.current[store.id] = placeOrderFn;
|
||||
onPlaceOrder={(fn) => {
|
||||
checkoutRefs.current[store.id] = fn;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -391,10 +320,9 @@ const CartPage = () => {
|
||||
</div>
|
||||
<div className={styles.priceQuantity}>
|
||||
<span className={styles.price}>
|
||||
{(
|
||||
parseFloat(item.product.price_amount) || 0
|
||||
).toFixed(2)}{" "}
|
||||
m.
|
||||
{isPriceZero(item.product.price_amount)
|
||||
? t("cart.pendingPriceTitle")
|
||||
: `${parseFloat(item.product.price_amount).toFixed(2)} m.`}
|
||||
</span>
|
||||
<div className={styles.quantityControls}>
|
||||
<button
|
||||
@@ -441,26 +369,38 @@ const CartPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ Store Summary - fiyatsız ürün varsa "Baha anyklamak" */}
|
||||
<div className={styles.storeSummary}>
|
||||
<div className={styles.cartContent}>
|
||||
<h3>
|
||||
{store.name} - {t("cart.basket")}:
|
||||
</h3>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>{t("cart.price")}:</span>
|
||||
<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>
|
||||
{hasZeroPrice ? (
|
||||
<div className={styles.summaryRow}>
|
||||
<span>{t("cart.total")}:</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>{t("cart.price")}:</span>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleOrderSubmit(store.id, store.items)}
|
||||
onClick={() => handleOrderSubmit(store.id)}
|
||||
className={styles.checkoutBtn}
|
||||
>
|
||||
{checkoutStores[store.id]
|
||||
@@ -472,7 +412,6 @@ const CartPage = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile sticky summary */}
|
||||
{/* <div className={styles.container}>
|
||||
<div className={styles.summaryCard} ref={expandedRef}>
|
||||
|
||||
@@ -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 {
|
||||
display: flex !important;
|
||||
@@ -7,41 +100,65 @@
|
||||
// Price Filter Styles
|
||||
.priceFilterContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.priceInput {
|
||||
width: 90px;
|
||||
padding: 6px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
width: 85%;
|
||||
&::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
.priceInput:focus {
|
||||
border-color: #6c63ff;
|
||||
border-color: #d32824;
|
||||
box-shadow: 0 0 0 3px rgba(211, 40, 36, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.priceDivider {
|
||||
font-size: 18px;
|
||||
color: #aaa;
|
||||
font-weight: bold;
|
||||
margin: 0 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filtersContainer{
|
||||
@@ -67,6 +184,16 @@
|
||||
color: #000000;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
@@ -282,10 +409,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.productGrid::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.productsContainer {
|
||||
flex: 1;
|
||||
|
||||
.productGrid {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
overflow: hidden !important;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TiTick } from "react-icons/ti";
|
||||
import { Divider } from "antd";
|
||||
import styles from "../CategoryPage.module.scss";
|
||||
|
||||
const CategoryFilters = ({
|
||||
@@ -18,10 +19,37 @@ const CategoryFilters = ({
|
||||
onBrandSelect,
|
||||
onBrandDeselect,
|
||||
onBrandSearchChange,
|
||||
sorting = "",
|
||||
onSortingChange = () => {},
|
||||
className,
|
||||
}) => {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -98,6 +126,23 @@ const CategoryFilters = ({
|
||||
)}
|
||||
<div className={styles.filterSection}>
|
||||
<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.priceInputGroup}>
|
||||
<span className={styles.priceLabel}>{t("category.minPrice")}</span>
|
||||
@@ -123,6 +168,27 @@ const CategoryFilters = ({
|
||||
/>
|
||||
</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
useGetFiltersQuery,
|
||||
useLazyGetFiltersQuery,
|
||||
} from "../../../app/api/filtersApi";
|
||||
import { useGetChannelsQuery } from "../../../app/api/channelsApi";
|
||||
|
||||
const useCategoryData = ({
|
||||
categoryId,
|
||||
collectionId,
|
||||
brandId,
|
||||
channelId,
|
||||
selectedFilterCategory,
|
||||
searchQuery,
|
||||
}) => {
|
||||
@@ -23,8 +25,9 @@ const useCategoryData = ({
|
||||
if (categoryId) return { category_id: categoryId };
|
||||
if (collectionId) return { collection_id: collectionId };
|
||||
if (brandId) return { brand_id: brandId };
|
||||
if (channelId) return { channel_id: channelId };
|
||||
return null;
|
||||
}, [categoryId, collectionId, brandId, selectedFilterCategory, searchQuery]);
|
||||
}, [categoryId, collectionId, brandId, channelId, selectedFilterCategory, searchQuery]);
|
||||
|
||||
const {
|
||||
data: filtersData,
|
||||
@@ -44,6 +47,19 @@ const useCategoryData = ({
|
||||
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(() => {
|
||||
if (!categoriesData?.data || !categoryId) return false;
|
||||
|
||||
@@ -92,8 +108,8 @@ const useCategoryData = ({
|
||||
setSelectedCategory(category);
|
||||
}, [categoryId, categoriesData]);
|
||||
|
||||
const isLoading = filtersLoading || collectionLoading;
|
||||
const hasError = filtersError || collectionError;
|
||||
const isLoading = filtersLoading || collectionLoading || channelsLoading;
|
||||
const hasError = filtersError || collectionError || channelsError;
|
||||
|
||||
return {
|
||||
categoriesData,
|
||||
@@ -101,6 +117,7 @@ const useCategoryData = ({
|
||||
isSubCategory,
|
||||
filtersData: activeFilters,
|
||||
collectionData,
|
||||
channelData,
|
||||
isLoading,
|
||||
hasError,
|
||||
fetchFilters,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
useGetCategoryProductsQuery,
|
||||
useLazyGetAllCategoryProductsPaginatedQuery,
|
||||
useGetCategoryProductsQuery,
|
||||
} from "../../../app/api/categories";
|
||||
import { useLazyGetBrandProductsQuery } from "../../../app/api/brandsApi";
|
||||
import { useLazyGetCollectionProductsPaginatedQuery } from "../../../app/api/collectionsApi";
|
||||
import { useLazyGetChannelProductsQuery } from "../../../app/api/channelsApi"; // EKLE
|
||||
|
||||
const useCategoryProducts = ({
|
||||
categoryId,
|
||||
collectionId,
|
||||
brandId,
|
||||
channelId,
|
||||
selectedCategory,
|
||||
isSubCategory,
|
||||
currentPage,
|
||||
@@ -17,296 +19,198 @@ const useCategoryProducts = ({
|
||||
selectedFilterBrand,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sorting,
|
||||
searchQuery,
|
||||
initialProducts = [],
|
||||
initialHasMore = true,
|
||||
}) => {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [products, setProducts] = useState(initialProducts);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const isFetchingRef = useRef(false);
|
||||
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 activeRequestId = useRef(0);
|
||||
|
||||
const shouldUseBaseQuery =
|
||||
categoryId &&
|
||||
!isSubCategory &&
|
||||
!searchQuery &&
|
||||
!selectedFilterCategory &&
|
||||
!selectedFilterBrand &&
|
||||
!brandId &&
|
||||
!collectionId;
|
||||
!collectionId &&
|
||||
!channelId;
|
||||
|
||||
const {
|
||||
data: paginatedCategoryProducts,
|
||||
isLoading: categoryLoading,
|
||||
isFetching: categoryFetching,
|
||||
} = useGetCategoryProductsQuery(
|
||||
{
|
||||
categoryId: categoryId,
|
||||
page: currentPage,
|
||||
min_price: minPrice || undefined,
|
||||
max_price: maxPrice || undefined,
|
||||
},
|
||||
{
|
||||
skip: !shouldUseBaseQuery,
|
||||
},
|
||||
);
|
||||
const { data: baseQueryData, isFetching: baseQueryFetching } =
|
||||
useGetCategoryProductsQuery(
|
||||
{
|
||||
categoryId,
|
||||
page: currentPage,
|
||||
min_price: minPrice || undefined,
|
||||
max_price: maxPrice || undefined,
|
||||
brands: selectedFilterBrand || undefined,
|
||||
sorting: sorting || undefined,
|
||||
},
|
||||
{ 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,
|
||||
{
|
||||
data: lazyCategoryProducts,
|
||||
isLoading: lazyCategoryLoading,
|
||||
isFetching: lazyCategoryFetching,
|
||||
reset: resetCategoryPaginated,
|
||||
},
|
||||
] = useLazyGetAllCategoryProductsPaginatedQuery();
|
||||
|
||||
const [
|
||||
fetchBrandPaginated,
|
||||
{
|
||||
data: paginatedBrandProducts,
|
||||
isLoading: brandPaginatedLoading,
|
||||
isFetching: brandFetching,
|
||||
reset: resetBrandPaginated,
|
||||
},
|
||||
] = useLazyGetBrandProductsQuery();
|
||||
|
||||
const [
|
||||
fetchCollectionPaginated,
|
||||
{
|
||||
data: paginatedCollectionProducts,
|
||||
isLoading: collectionPaginatedLoading,
|
||||
isFetching: collectionFetching,
|
||||
reset: resetCollectionPaginated,
|
||||
},
|
||||
] = useLazyGetCollectionProductsPaginatedQuery();
|
||||
fetchChannelPaginated,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setProducts([]);
|
||||
setHasMore(true);
|
||||
if (!shouldUseBaseQuery || !baseQueryData) return;
|
||||
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(() => {
|
||||
if (searchQuery) return;
|
||||
if (shouldUseBaseQuery || searchQuery) return;
|
||||
|
||||
if (lastFetchKeyRef.current === fetchKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
console.log("🔥 LAZY EFFECT TRIGGERED", {
|
||||
shouldUseBaseQuery,
|
||||
categoryId,
|
||||
collectionId,
|
||||
brandId,
|
||||
channelId,
|
||||
isSubCategory,
|
||||
selectedFilterCategory,
|
||||
selectedCategory,
|
||||
});
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const snapshot = {
|
||||
currentPage,
|
||||
selectedFilterCategory,
|
||||
categoryId,
|
||||
isSubCategory,
|
||||
brandId,
|
||||
collectionId,
|
||||
channelId,
|
||||
selectedFilterBrand,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sorting,
|
||||
};
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
isFetchingRef.current = true;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
const requestId = ++activeRequestId.current;
|
||||
setIsFetching(true);
|
||||
|
||||
const executeFetch = async () => {
|
||||
const run = async () => {
|
||||
try {
|
||||
if (selectedFilterBrand) {
|
||||
await fetchBrandPaginated({
|
||||
id: selectedFilterBrand,
|
||||
...fetchParams,
|
||||
});
|
||||
const {
|
||||
fetchCategoryPaginated,
|
||||
fetchBrandPaginated,
|
||||
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;
|
||||
}
|
||||
|
||||
if (selectedFilterCategory) {
|
||||
await fetchCategoryPaginated({
|
||||
category: {
|
||||
id: selectedFilterCategory,
|
||||
children: [],
|
||||
},
|
||||
...fetchParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = result.data || [];
|
||||
const hasNextPage =
|
||||
result.pagination?.hasMorePages ||
|
||||
!!result.pagination?.next_page_url ||
|
||||
false;
|
||||
|
||||
if (categoryId && isSubCategory) {
|
||||
await fetchCategoryPaginated({
|
||||
category: {
|
||||
id: parseInt(categoryId),
|
||||
children: [],
|
||||
},
|
||||
...fetchParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setProducts((prev) => {
|
||||
if (snapshot.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;
|
||||
});
|
||||
|
||||
if (brandId) {
|
||||
await fetchBrandPaginated({
|
||||
id: brandId,
|
||||
...fetchParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
await fetchCollectionPaginated({
|
||||
collectionId,
|
||||
...fetchParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Fetch error:", error);
|
||||
}
|
||||
setHasMore(data.length > 0 ? hasNextPage : false);
|
||||
} catch (err) {
|
||||
if (requestId !== activeRequestId.current) return;
|
||||
console.error("Fetch error:", err);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
isFetchingRef.current = false;
|
||||
if (requestId === activeRequestId.current) {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
executeFetch();
|
||||
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, [
|
||||
fetchKey,
|
||||
shouldUseBaseQuery,
|
||||
searchQuery,
|
||||
selectedFilterBrand,
|
||||
currentPage,
|
||||
selectedFilterCategory,
|
||||
categoryId,
|
||||
isSubCategory,
|
||||
brandId,
|
||||
collectionId,
|
||||
fetchParams,
|
||||
fetchCategoryPaginated,
|
||||
fetchBrandPaginated,
|
||||
fetchCollectionPaginated,
|
||||
channelId,
|
||||
selectedFilterBrand,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sorting,
|
||||
// ✅ fetcher fonksiyonlar dependency'den tamamen çıktı
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
const isLoading = shouldUseBaseQuery ? baseQueryFetching : isFetching;
|
||||
|
||||
return {
|
||||
products,
|
||||
@@ -318,3 +222,4 @@ const useCategoryProducts = ({
|
||||
};
|
||||
|
||||
export default useCategoryProducts;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useParams, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,26 +13,56 @@ import CategoryFilters from "./components/CategoryFilters";
|
||||
import CategoryBreadcrumbs from "./components/CategoryBreadcrumbs";
|
||||
import useCategoryData from "./hooks/useCategoryData";
|
||||
import useCategoryProducts from "./hooks/useCategoryProducts";
|
||||
import Carconfigurator from "../../components/CarConfigurator/Carconfigurator";
|
||||
|
||||
import MobilePhoneCard from "./components/Mobilephonecard";
|
||||
|
||||
const CategoryPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { categoryId, collectionId, brandId } = useParams();
|
||||
const { categoryId, collectionId, brandId, channelId } = useParams();
|
||||
const location = useLocation();
|
||||
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,
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
});
|
||||
sorting: "",
|
||||
}));
|
||||
|
||||
const [filterState, setFilterState] = useState({
|
||||
const [filterState, setFilterState] = useState(() => getSavedState("filterState", {
|
||||
selectedFilterCategory: null,
|
||||
selectedFilterBrand: null,
|
||||
brandSearchQuery: "",
|
||||
});
|
||||
}));
|
||||
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||
@@ -45,17 +73,12 @@ const CategoryPage = () => {
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const routeKey = useMemo(
|
||||
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}`,
|
||||
[categoryId, collectionId, brandId]
|
||||
);
|
||||
|
||||
const prevRouteRef = useRef(routeKey);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const searchResults = useMemo(
|
||||
() => location.state?.searchData?.data || [],
|
||||
[location.state?.searchData?.data]
|
||||
[location.state?.searchData?.data],
|
||||
);
|
||||
const searchQuery = location.state?.searchQuery || null;
|
||||
|
||||
@@ -65,6 +88,7 @@ const CategoryPage = () => {
|
||||
isSubCategory,
|
||||
filtersData,
|
||||
collectionData,
|
||||
channelData,
|
||||
isLoading: dataLoading,
|
||||
hasError: dataError,
|
||||
fetchFilters,
|
||||
@@ -72,6 +96,7 @@ const CategoryPage = () => {
|
||||
categoryId,
|
||||
collectionId,
|
||||
brandId,
|
||||
channelId,
|
||||
selectedFilterCategory: filterState.selectedFilterCategory,
|
||||
searchQuery,
|
||||
});
|
||||
@@ -85,6 +110,7 @@ const CategoryPage = () => {
|
||||
} = useCategoryProducts({
|
||||
categoryId,
|
||||
collectionId,
|
||||
channelId,
|
||||
brandId,
|
||||
selectedCategory,
|
||||
isSubCategory,
|
||||
@@ -93,7 +119,10 @@ const CategoryPage = () => {
|
||||
selectedFilterBrand: filterState.selectedFilterBrand,
|
||||
minPrice: pageState.minPrice,
|
||||
maxPrice: pageState.maxPrice,
|
||||
sorting: pageState.sorting,
|
||||
searchQuery,
|
||||
initialProducts: getSavedState("products", []),
|
||||
initialHasMore: getSavedState("hasMore", true),
|
||||
});
|
||||
const isMobilePhoneView =
|
||||
(Number(categoryId) === 531 ||
|
||||
@@ -103,21 +132,51 @@ const CategoryPage = () => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
prevRouteRef.current = routeKey;
|
||||
const savedScroll = getSavedState("scroll", 0);
|
||||
if (savedScroll > 0) {
|
||||
setTimeout(() => window.scrollTo(0, savedScroll), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevRouteRef.current === routeKey) return;
|
||||
if (prevRouteRef.current === routeKey && !location.state?.clearFilters) return;
|
||||
|
||||
prevRouteRef.current = routeKey;
|
||||
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setFilterState({
|
||||
selectedFilterCategory: null,
|
||||
selectedFilterBrand: null,
|
||||
brandSearchQuery: "",
|
||||
});
|
||||
const shouldClear = location.state?.clearFilters;
|
||||
|
||||
const savedPageState = shouldClear ? null : getSavedStateByKey(routeKey, "pageState");
|
||||
const savedFilterState = shouldClear ? null : getSavedStateByKey(routeKey, "filterState");
|
||||
const savedProducts = shouldClear ? null : getSavedStateByKey(routeKey, "products");
|
||||
const savedHasMore = shouldClear ? null : getSavedStateByKey(routeKey, "hasMore");
|
||||
|
||||
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) {
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
@@ -125,11 +184,52 @@ const CategoryPage = () => {
|
||||
}, [
|
||||
routeKey,
|
||||
location.state?.clearFilters,
|
||||
location.pathname,
|
||||
navigate,
|
||||
setAllProducts,
|
||||
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(() => {
|
||||
let list = searchQuery ? searchResults : allProducts;
|
||||
|
||||
@@ -160,7 +260,7 @@ const CategoryPage = () => {
|
||||
if (filterState.selectedFilterCategory) {
|
||||
const cat = findCategoryById(
|
||||
categoriesData?.data,
|
||||
filterState.selectedFilterCategory
|
||||
filterState.selectedFilterCategory,
|
||||
);
|
||||
return cat?.name || "Category";
|
||||
}
|
||||
@@ -185,7 +285,12 @@ const CategoryPage = () => {
|
||||
selectedFilterBrand: null,
|
||||
}));
|
||||
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setPageState((prev) => ({
|
||||
currentPage: 1,
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
sorting: prev.sorting,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
|
||||
@@ -193,15 +298,15 @@ const CategoryPage = () => {
|
||||
};
|
||||
|
||||
const handleFilterCategoryDeselect = () => {
|
||||
setFilterState((prev) => ({
|
||||
setFilterState((prev) => ({ ...prev, selectedFilterCategory: null }));
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
selectedFilterCategory: null,
|
||||
currentPage: 1,
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
}));
|
||||
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
|
||||
if (categoryId) fetchFilters({ category_id: categoryId });
|
||||
};
|
||||
|
||||
@@ -211,32 +316,29 @@ const CategoryPage = () => {
|
||||
selectedFilterBrand: brandId,
|
||||
}));
|
||||
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setPageState((prev) => ({
|
||||
currentPage: 1,
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
sorting: prev.sorting,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
};
|
||||
|
||||
const handleFilterBrandDeselect = () => {
|
||||
setFilterState((prev) => ({
|
||||
setFilterState((prev) => ({ ...prev, selectedFilterBrand: null }));
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
selectedFilterBrand: null,
|
||||
currentPage: 1,
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
}));
|
||||
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (targetId) => {
|
||||
setFilterState({
|
||||
selectedFilterCategory: null,
|
||||
selectedFilterBrand: null,
|
||||
brandSearchQuery: "",
|
||||
});
|
||||
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
|
||||
navigate(`/category/${targetId}`, {
|
||||
replace: false,
|
||||
state: { clearFilters: true, timestamp: Date.now() },
|
||||
@@ -272,18 +374,35 @@ const CategoryPage = () => {
|
||||
|
||||
return (
|
||||
<div className={styles.categoryPage}>
|
||||
{(categoryId || filterState.selectedFilterCategory) && (
|
||||
<CategoryBreadcrumbs
|
||||
categoriesData={categoriesData}
|
||||
categoryId={filterState.selectedFilterCategory || categoryId}
|
||||
onCategoryClick={handleCategoryClick}
|
||||
/>
|
||||
)}
|
||||
{channelId && channelData ? (
|
||||
<div className={styles.channelHeader}>
|
||||
{channelData.media?.[0]?.thumbnail && (
|
||||
<img
|
||||
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>
|
||||
<p className={styles.sum}>
|
||||
{t("category.total")}: {totalItems} {t("category.items")}
|
||||
</p>
|
||||
<h2>{pageTitle}</h2>
|
||||
<p className={styles.sum}>
|
||||
{t("category.total")}: {totalItems} {t("category.items")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.bars}>
|
||||
<button
|
||||
@@ -310,22 +429,22 @@ const CategoryPage = () => {
|
||||
minPrice={pageState.minPrice}
|
||||
maxPrice={pageState.maxPrice}
|
||||
onMinPriceChange={(value) => {
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
minPrice: value,
|
||||
currentPage: 1,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
}}
|
||||
onMaxPriceChange={(value) => {
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
maxPrice: value,
|
||||
currentPage: 1,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
}}
|
||||
onCategorySelect={handleFilterCategorySelect}
|
||||
onCategoryDeselect={handleFilterCategoryDeselect}
|
||||
@@ -334,6 +453,15 @@ const CategoryPage = () => {
|
||||
onBrandSearchChange={(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>
|
||||
|
||||
@@ -348,22 +476,22 @@ const CategoryPage = () => {
|
||||
minPrice={pageState.minPrice}
|
||||
maxPrice={pageState.maxPrice}
|
||||
onMinPriceChange={(value) => {
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
minPrice: value,
|
||||
currentPage: 1,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
}}
|
||||
onMaxPriceChange={(value) => {
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
setPageState((prev) => ({
|
||||
...prev,
|
||||
maxPrice: value,
|
||||
currentPage: 1,
|
||||
}));
|
||||
setAllProducts([]);
|
||||
setHasMore(true);
|
||||
}}
|
||||
onCategorySelect={handleFilterCategorySelect}
|
||||
onCategoryDeselect={handleFilterCategoryDeselect}
|
||||
@@ -372,9 +500,22 @@ const CategoryPage = () => {
|
||||
onBrandSearchChange={(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}>
|
||||
{(Number(categoryId) === 1136 ||
|
||||
Number(filterState.selectedFilterCategory) === 1136) && (
|
||||
<Carconfigurator />
|
||||
)}
|
||||
{isInitialLoad ? (
|
||||
<div className={styles.loaderContainer}>
|
||||
<Loader />
|
||||
@@ -385,12 +526,14 @@ const CategoryPage = () => {
|
||||
next={loadMoreData}
|
||||
hasMore={hasMore}
|
||||
scrollThreshold={0.8}
|
||||
scrollableTarget={null}
|
||||
style={{ overflow: "hidden" }}
|
||||
loader={
|
||||
<div className={styles.loaderContainer}>
|
||||
<div className={`${styles.loaderContainer} `}>
|
||||
<Loader />
|
||||
</div>
|
||||
}
|
||||
className={`${styles.productGrid} ${
|
||||
className={`${styles.productGrid} ${
|
||||
isMobilePhoneView ? styles.mobilePhoneGrid : ""
|
||||
}`}
|
||||
>
|
||||
@@ -409,7 +552,7 @@ const CategoryPage = () => {
|
||||
showFavoriteButton
|
||||
showAddToCart
|
||||
/>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<div className={styles.termItem}>
|
||||
{/* <div className={styles.termItem}>
|
||||
<p>
|
||||
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;
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -140,6 +141,7 @@
|
||||
.row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
@media screen and (max-width: 640px) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -157,10 +159,10 @@
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
width: 25%;
|
||||
@media screen and (max-width: 640px) {
|
||||
width: 50%;
|
||||
}
|
||||
// width: 25%;
|
||||
// @media screen and (max-width: 640px) {
|
||||
// width: 50%;
|
||||
// }
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import styles from "./OrderDetail.module.scss";
|
||||
import { Ban, CircleCheck, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetOrderByIdQuery } from "../../app/api/orderApi"; // Update with your correct path
|
||||
import track from "../../assets/track.jpg"; // Keep for delivery service icon
|
||||
import { useGetOrderByIdQuery } from "../../app/api/orderApi";
|
||||
import track from "../../assets/track.jpg";
|
||||
import Loader from "../../components/Loader/index";
|
||||
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 { t } = useTranslation();
|
||||
const { id } = useParams(); // Get the order ID from URL params
|
||||
const { data: orderData, isLoading, error } = useGetOrderByIdQuery(id);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
// Format date function
|
||||
const { data: orderData, isLoading, error } = useGetOrderByIdQuery(id);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("tk-TM", {
|
||||
return new Date(dateString).toLocaleString("tk-TM", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Format delivery time for display
|
||||
const formatDeliveryTime = (time, date) => {
|
||||
try {
|
||||
const deliveryDate = new Date(date);
|
||||
const formattedDate = deliveryDate.toLocaleDateString("tk-TM", {
|
||||
const formatted = new Date(date).toLocaleDateString("tk-TM", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return `${time} (${formattedDate})`;
|
||||
} catch (e) {
|
||||
return `${time}`;
|
||||
return `${time} (${formatted})`;
|
||||
} catch {
|
||||
return time;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate total order amount
|
||||
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
|
||||
.reduce(
|
||||
(sum, item) => sum + parseFloat(item.unit_price_amount) * item.quantity,
|
||||
0
|
||||
0,
|
||||
)
|
||||
.toFixed(2);
|
||||
};
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
// Handle error state
|
||||
if (error)
|
||||
return (
|
||||
<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>;
|
||||
|
||||
// Calculate total
|
||||
const totalAmount = calculateTotal(orderData.orderItems);
|
||||
|
||||
return (
|
||||
@@ -84,39 +82,10 @@ const OrderDetail = () => {
|
||||
<h1>
|
||||
{t("order.orderNumber")}: {orderData.id}
|
||||
</h1>
|
||||
<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 className={styles.Buttons} />
|
||||
</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.rowContainer}>
|
||||
<div className={styles.row}>
|
||||
@@ -132,7 +101,7 @@ const OrderDetail = () => {
|
||||
<span>
|
||||
{formatDeliveryTime(
|
||||
orderData.delivery_time,
|
||||
orderData.delivery_at
|
||||
orderData.delivery_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -144,11 +113,26 @@ const OrderDetail = () => {
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<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>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
@@ -165,9 +149,12 @@ const OrderDetail = () => {
|
||||
<tbody>
|
||||
{orderData.orderItems.map((item, index) => {
|
||||
const product = item.product;
|
||||
const itemTotal = (
|
||||
parseFloat(item.unit_price_amount) * item.quantity
|
||||
).toFixed(2);
|
||||
const zeroPriceItem = isPriceZero(item.unit_price_amount);
|
||||
const itemTotal = zeroPriceItem
|
||||
? null
|
||||
: (
|
||||
parseFloat(item.unit_price_amount) * item.quantity
|
||||
).toFixed(2);
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
@@ -181,27 +168,50 @@ const OrderDetail = () => {
|
||||
<td>{product.name}</td>
|
||||
<td>{product.brand || "-"}</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>{itemTotal} m.</td>
|
||||
<td>
|
||||
{itemTotal === null ? (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<PendingPriceBadge />
|
||||
</span>
|
||||
) : (
|
||||
`${itemTotal} m.`
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{/* Add delivery service row if shipping method exists */}
|
||||
|
||||
{orderData.shipping_method && (
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src={track}
|
||||
alt="Delivery Service"
|
||||
className={styles.image}
|
||||
/>
|
||||
<img src={track} alt="Delivery" className={styles.image} />
|
||||
</td>
|
||||
<td>Eltip bermek hyzmaty</td>
|
||||
<td>Beýleki</td>
|
||||
<td>DELIVERY</td>
|
||||
<td>10.00 m.</td>{" "}
|
||||
{/* You may need to get actual delivery cost from API */}
|
||||
<td>10.00 m.</td>
|
||||
<td>1</td>
|
||||
<td>10.00 m.</td>
|
||||
</tr>
|
||||
@@ -210,13 +220,15 @@ const OrderDetail = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile View */}
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className={styles.productList}>
|
||||
{orderData.orderItems.map((item, index) => {
|
||||
const product = item.product;
|
||||
const itemTotal = (
|
||||
parseFloat(item.unit_price_amount) * item.quantity
|
||||
).toFixed(2);
|
||||
const zeroPriceItem = isPriceZero(item.unit_price_amount);
|
||||
const itemTotal = zeroPriceItem
|
||||
? null
|
||||
: (parseFloat(item.unit_price_amount) * item.quantity).toFixed(2);
|
||||
|
||||
return (
|
||||
<div className={styles.card} key={index}>
|
||||
@@ -233,18 +245,30 @@ const OrderDetail = () => {
|
||||
{t("order.quantity")}: {item.quantity}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Add delivery service card if shipping method exists */}
|
||||
{orderData.shipping_method && (
|
||||
|
||||
{/* {orderData.shipping_method && (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.imageContainer}>
|
||||
<img src={track} alt="Delivery Service" />
|
||||
<img src={track} alt="Delivery" />
|
||||
</div>
|
||||
<div className={styles.detailsMobile}>
|
||||
<h3 className={styles.title}>Beýleki</h3>
|
||||
@@ -257,7 +281,7 @@ const OrderDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// SargytlarymComponent.module.scss
|
||||
.container {
|
||||
padding: 15px 24px 0 24px;
|
||||
padding: 15px 24px 24px 24px;
|
||||
max-width: 1366px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
a{
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
@@ -121,3 +121,106 @@
|
||||
font-weight: 700;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
// Orders.jsx
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import styles from "./Orders.module.scss";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetOrdersQuery } from "../../app/api/orderApi"; // Update with your correct path
|
||||
import EmptyOrderState from "./emptyOrder"; // Import the EmptyOrderState component
|
||||
import { useGetOrdersQuery } from "../../app/api/orderApi";
|
||||
import EmptyOrderState from "./emptyOrder";
|
||||
import Loader from "../../components/Loader/index";
|
||||
import { Result, Button } from "antd";
|
||||
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 { t } = useTranslation();
|
||||
const { data: orders, isLoading, error } = useGetOrdersQuery();
|
||||
const navigate = useNavigate();
|
||||
// Function to format date - implement this or use a library like date-fns
|
||||
|
||||
const formatOrderDate = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("tk-TM", {
|
||||
return new Date(dateString).toLocaleString("tk-TM", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
// Handle error state
|
||||
if (error)
|
||||
return (
|
||||
<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 (
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.title}>Sargytlarym</h2>
|
||||
|
||||
{/* Desktop table view */}
|
||||
{/* Desktop table */}
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
@@ -69,11 +70,11 @@ const Orders = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => {
|
||||
// Calculate total order amount
|
||||
const hasZeroPrice = orderHasZeroPrice(order.orderItems);
|
||||
const totalAmount = order.orderItems.reduce(
|
||||
(sum, item) =>
|
||||
sum + parseFloat(item.unit_price_amount) * item.quantity,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -81,7 +82,19 @@ const Orders = () => {
|
||||
<td>{order.id}</td>
|
||||
<td>{formatOrderDate(order.delivery_at)}</td>
|
||||
<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>{order.payment_type}</td>
|
||||
<td>{order.status}</td>
|
||||
@@ -99,50 +112,72 @@ const Orders = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card view */}
|
||||
{/* Mobile cards */}
|
||||
<div className={styles.Mobilecontainer}>
|
||||
{orders.map((order) => {
|
||||
const hasZeroPrice = orderHasZeroPrice(order.orderItems);
|
||||
const totalAmount = order.orderItems.reduce(
|
||||
(sum, item) =>
|
||||
sum + parseFloat(item.unit_price_amount) * item.quantity,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Link to={`/orderdetail/${order.id}`} key={order.id}>
|
||||
<div className={styles.orderCard}>
|
||||
<div className={styles.orderRow}>
|
||||
<span className={styles.label}>
|
||||
{t("order.orderNumber")}:
|
||||
</span>
|
||||
<span className={styles.value}>{order.id}</span>
|
||||
</div>
|
||||
<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}>
|
||||
{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
|
||||
key={order.id}
|
||||
className={styles.orderCard}
|
||||
onClick={(e) => {
|
||||
// Modal veya badge içerisine tıklandığında yönlendirmeyi engelle
|
||||
if (
|
||||
e.target.closest(`.${styles.pendingPriceBadgeWrapper}`) ||
|
||||
e.target.closest(".ant-modal-root") ||
|
||||
e.target.closest(".ant-modal-wrap")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
navigate(`/orderdetail/${order.id}`);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div className={styles.orderRow}>
|
||||
<span className={styles.label}>{t("order.orderNumber")}:</span>
|
||||
<span className={styles.value}>{order.id}</span>
|
||||
</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>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ─── Breadcrumb ───────────────────────────────────────────────────
|
||||
.breadcrumb {
|
||||
margin-bottom: 15px;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
align-items: flex-start;
|
||||
// background-color: #fff;
|
||||
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 1.25rem;
|
||||
// padding: 1.25rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
// padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sol: resim kolonu ────────────────────────────────────────────
|
||||
.productImage {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
// padding: 20px;
|
||||
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) {
|
||||
padding: 5px;
|
||||
width: 45%;
|
||||
border-radius: 8px;
|
||||
// padding: 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px) {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 99%;
|
||||
height: auto;
|
||||
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 {
|
||||
width: 60%;
|
||||
@media screen and (max-width: 639px) {
|
||||
width: 100%;
|
||||
}
|
||||
@media screen and (max-width: 520px) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.productTitle {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #000000;
|
||||
// ─── Quantity controls ────────────────────────────────────────────
|
||||
.quantityControls {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #d32824;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.productDescription {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
margin-bottom: 24px;
|
||||
.quantityBtn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
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 {
|
||||
background: #f5f5f5;
|
||||
// padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -98,112 +297,180 @@
|
||||
.metaLabel {
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.Btn {
|
||||
|
||||
.mobileBtnContainer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@media screen and (max-width: 639px) {
|
||||
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));
|
||||
}
|
||||
gap: 8px;
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
// ─── Similar products ─────────────────────────────────────────────
|
||||
.similarProducts {
|
||||
margin-top: 48px;
|
||||
margin-top: 40px;
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 20px;
|
||||
@@ -216,117 +483,27 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
|
||||
@media screen and (max-width: 1230px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(228px, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(234px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media screen and (max-width: 767px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 510px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.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;
|
||||
}
|
||||
|
||||
// ─── Misc ─────────────────────────────────────────────────────────
|
||||
.modalButton {
|
||||
// Style for modal buttons
|
||||
padding: 6px 15px;
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
@@ -334,3 +511,21 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wishlistButton {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.outOfStock {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
|
||||
import { FaShoppingCart } from "react-icons/fa";
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
import ImageCarousel from "../../components/ProductCard/imageCarousel/index";
|
||||
import Loader from "../../components/Loader/index";
|
||||
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 = ({
|
||||
productProp,
|
||||
@@ -38,27 +42,107 @@ const ProductPage = ({
|
||||
const navigate = useNavigate();
|
||||
const { productId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: productResponse,
|
||||
error: productError,
|
||||
isLoading: productLoading,
|
||||
} = useGetProductByIdQuery(productId);
|
||||
|
||||
const {
|
||||
data: similarProductsResponse,
|
||||
error: similarProductsError,
|
||||
isLoading: similarProductsLoading,
|
||||
} = useGetRelatedProductsQuery(productId);
|
||||
|
||||
const product = productResponse?.data;
|
||||
const similarProducts = similarProductsResponse?.data;
|
||||
|
||||
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
|
||||
const [addFavorite] = useAddFavoriteMutation();
|
||||
const [removeFavorite] = useRemoveFavoriteMutation();
|
||||
const { data: favoriteProducts = [] } = useGetFavoritesQuery();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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 [addToCart] = useAddToCartMutation();
|
||||
@@ -73,7 +157,7 @@ const ProductPage = ({
|
||||
useEffect(() => {
|
||||
const qty = parseInt(
|
||||
cartItem?.quantity || cartItem?.product_quantity || 0,
|
||||
10
|
||||
10,
|
||||
);
|
||||
setLocalQuantity(qty);
|
||||
setPendingQuantity(qty);
|
||||
@@ -83,7 +167,7 @@ const ProductPage = ({
|
||||
useEffect(() => {
|
||||
if (Array.isArray(favoriteProducts)) {
|
||||
const isFav = favoriteProducts.some(
|
||||
(fav) => fav.product?.id === product?.id
|
||||
(fav) => fav.product?.id === product?.id,
|
||||
);
|
||||
setLocalIsFavorite(isFav);
|
||||
}
|
||||
@@ -180,10 +264,9 @@ const ProductPage = ({
|
||||
useEffect(() => {
|
||||
const serverQty = parseInt(
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -197,7 +280,6 @@ const ProductPage = ({
|
||||
}).unwrap();
|
||||
} catch (error) {
|
||||
console.error("Failed to update cart item:", error);
|
||||
// Hata durumunda geri al
|
||||
setLocalQuantity(serverQty);
|
||||
setPendingQuantity(serverQty);
|
||||
} finally {
|
||||
@@ -225,12 +307,74 @@ const ProductPage = ({
|
||||
|
||||
if (!product) return <div>Can not find product</div>;
|
||||
|
||||
const imageUrl = product.media?.[0]?.thumbnail || "";
|
||||
const categoryName = product.categories?.[0]?.name || "Category";
|
||||
const categoryId = product.categories?.[0]?.id;
|
||||
|
||||
const handleCategoryClick = (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 (
|
||||
<div className={styles.container}>
|
||||
{/* Breadcrumb */}
|
||||
@@ -242,9 +386,10 @@ const ProductPage = ({
|
||||
<span>{product?.name || "Product"}</span>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
{/* ── 3 kolon ana section ── */}
|
||||
<div className={styles.productSection}>
|
||||
<div className={styles.productImage}>
|
||||
{/* KOLON 1: Resim */}
|
||||
<div className={styles.productImage} ref={imageColRef}>
|
||||
<ImageCarousel
|
||||
images={product.media}
|
||||
altText={product.name}
|
||||
@@ -252,181 +397,165 @@ const ProductPage = ({
|
||||
isDetailView={true}
|
||||
/>
|
||||
</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.metaItem}>
|
||||
<h1 className={styles.productTitle}>{product.name}</h1>
|
||||
|
||||
{/* <div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>
|
||||
{t("product.productCode")}
|
||||
</span>
|
||||
<span className={styles.metaValue}>{product.id}</span>
|
||||
</div>
|
||||
|
||||
{product.barcode && (
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>{t("product.barCode")}</span>
|
||||
<span className={styles.metaValue}>{product.barcode}</span>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{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.metaValue}>{product.brand.name}</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{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.metaValue}>
|
||||
{product.channel[0].name}
|
||||
</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 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 && (
|
||||
<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
|
||||
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 className={styles.mobileBtnContainer}>
|
||||
<CartButtons />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
<ReviewSection
|
||||
productId={productId}
|
||||
existingReviews={product.reviews_resources}
|
||||
@@ -452,6 +581,7 @@ const ProductPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock modal */}
|
||||
<Modal
|
||||
title={t("common.warning")}
|
||||
open={stockErrorModalVisible}
|
||||
@@ -480,3 +610,4 @@ const ProductPage = ({
|
||||
};
|
||||
|
||||
export default ProductPage;
|
||||
|
||||
|
||||
143
src/pages/Stores/Stores.module.scss
Normal 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
@@ -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;
|
||||
@@ -2,6 +2,9 @@ import React, { useState, useEffect } from "react";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import CategorySection from "../../components/CategorySection/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 { useGetCollectionsQuery } from "../../app/api/collectionsApi";
|
||||
import PageLoader from "../../components/Loader/pageLoader";
|
||||
@@ -20,7 +23,6 @@ const Home = () => {
|
||||
const processCollections = async (collectionsData) => {
|
||||
if (!collectionsData || !collectionsData.data) return [];
|
||||
|
||||
// Cache the processed collections to prevent duplicate processing
|
||||
const collectionsWithProducts = [];
|
||||
|
||||
for (const collection of collectionsData.data) {
|
||||
@@ -44,8 +46,6 @@ const Home = () => {
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -71,7 +71,6 @@ const Home = () => {
|
||||
setPage(page + 1);
|
||||
}
|
||||
|
||||
// Check if we've loaded all collections
|
||||
if (endIndex >= collections.length) {
|
||||
setHasMore(false);
|
||||
}
|
||||
@@ -80,7 +79,6 @@ const Home = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// if (isLoading) return <PageLoader />;
|
||||
if (error)
|
||||
return (
|
||||
<div>
|
||||
@@ -100,6 +98,9 @@ const Home = () => {
|
||||
return (
|
||||
<div className={styles.home}>
|
||||
<Carousel />
|
||||
<CategoryCarousel />
|
||||
<HomeBrands />
|
||||
<FlashSales />
|
||||
<div className={styles.sections}>
|
||||
<InfiniteScroll
|
||||
dataLength={visibleCollections.length}
|
||||
@@ -113,7 +114,7 @@ const Home = () => {
|
||||
<CategorySection
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
preventEmptyRender={true} // Add a prop to prevent rendering empty collections
|
||||
preventEmptyRender={true}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
@@ -122,4 +123,4 @@ const Home = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
export default Home;
|
||||
@@ -20,6 +20,8 @@ const ContactUs = lazy(() => import("./pages/ContactUs/index.jsx"));
|
||||
const DeliveryTerms = lazy(() => import("./pages/DeliveryTerms/index.jsx"));
|
||||
const AboutUs = lazy(() => import("./pages/AboutUs/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() {
|
||||
const routes = useRoutes([
|
||||
@@ -33,12 +35,14 @@ export default function Router() {
|
||||
children: [
|
||||
{ path: "/", element: <Home /> },
|
||||
{ path: "/brands", element: <BrandsPage /> },
|
||||
{ path: "/stores", element: <StoresPage /> },
|
||||
{ path: "/brands/:brandId", element: <Category /> },
|
||||
{ path: "/cart", element: <CartPage /> },
|
||||
{ path: "/wishlist", element: <WishList /> },
|
||||
{ path: "/category/:categoryId", element: <Category /> },
|
||||
{ path: "/search", element: <Category /> },
|
||||
{ path: "/collections/:collectionId", element: <Category /> },
|
||||
{ path: "/channel/:channelId", element: <Category /> },
|
||||
{ path: "/product/:productId", element: <ProductDetail /> },
|
||||
{ path: "/profile", element: <ProfileMenu /> },
|
||||
{ path: "/orders", element: <Orders /> },
|
||||
@@ -47,6 +51,7 @@ export default function Router() {
|
||||
{ path: "/delivery-and-payment", element: <DeliveryTerms /> },
|
||||
{ path: "/about-us", element: <AboutUs /> },
|
||||
{ path: "/privacy-policy", element: <PrivacyPolicy /> },
|
||||
{ path: "/carconfigurator-admin", element: <AdminPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'fs';
|
||||
|
||||
// https://vite.dev/config/
|
||||
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: {
|
||||
host: true,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||