Compare commits

...

31 Commits

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

10
ecosystem.config.js Normal file
View File

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

28
fetch.js Normal file
View File

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

615
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@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",

View File

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

File diff suppressed because one or more lines are too long

41
server.js Normal file
View File

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

View File

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

View File

@@ -72,7 +72,7 @@ const customBaseQuery = async (args, api, extraOptions) => {
"Content-Type": "application/json",
"Api-Token": import.meta.env.VITE_API_TOKEN || "hello-mf-s",
},
}
},
);
const data = await guestTokenResponse.json();
@@ -120,5 +120,6 @@ const customBaseQuery = async (args, api, extraOptions) => {
export const baseApi = createApi({
reducerPath: "api",
baseQuery: customBaseQuery,
tagTypes: ["Favorites", "cartItems", "Orders"],
endpoints: () => ({}),
});

View File

@@ -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}` : ""}`;
},
@@ -25,21 +18,22 @@ export const brandsApi = baseApi.injectEndpoints({
query: (brandId) => `/brands/${brandId}`,
transformResponse: (response) => response.data || response,
}),
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}`;
if (limit) {
url += `&limit=${limit}`;
}
return url;
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);
return `/brands/${id}/products?${urlParams.toString()}`;
},
transformResponse: (response) => ({
data: response.data || response,

View File

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

View File

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

View File

@@ -5,39 +5,35 @@ export const collectionsApi = baseApi.injectEndpoints({
getCollections: builder.query({
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) => ({

View File

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

View File

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

View File

@@ -1,29 +1,36 @@
// hooks/useCart.js - YENİ DOSYA
import { useMemo } from 'react';
import { useGetCartQuery } from './cartApi';
// hooks/useCart.js
import { useMemo } from "react";
import { useGetCartQuery } from "./cartApi";
export const useCart = () => {
const { data: cartData, ...rest } = useGetCartQuery(undefined, {
const queryResult = useGetCartQuery(undefined, {
pollingInterval: 0,
refetchOnMountOrArgChange: false,
refetchOnMountOrArgChange: false, // Cache'den kullan, gereksiz GET'i engelle
refetchOnFocus: false,
refetchOnReconnect: false,
});
const { data: response = {} } = queryResult;
const cartData = response.data || {};
const cartItems = useMemo(() => {
if (!cartData?.data || typeof cartData.data !== 'object') return [];
return Object.values(cartData.data).flat();
if (!cartData || typeof cartData !== "object") return [];
return Object.values(cartData).flat();
}, [cartData]);
const cartCount = useMemo(() => {
return cartItems.reduce((total, item) => {
return total + (parseInt(item.product_quantity, 10) || 0);
const qty = parseInt(item.product_quantity, 10) || 0;
return total + qty;
}, 0);
}, [cartItems]);
const getCartItem = (productId) => {
if (!productId) return null;
const pid = String(productId);
return cartItems.find(
item => item.product?.id === productId || item.product_id === productId
(item) =>
String(item.product?.id) === pid || String(item.product_id) === pid,
);
};
@@ -32,6 +39,6 @@ export const useCart = () => {
cartItems,
cartCount,
getCartItem,
...rest
...queryResult,
};
};
};

View File

@@ -1,55 +1,12 @@
import { configureStore } from "@reduxjs/toolkit";
import { baseApi } from "./api/baseApi";
import { categoriesApi } from "./api/categories";
import { searchApi } from "./api/searchApi";
import { cartApi } from "./api/cartApi";
import { brandsApi } from "./api/brandsApi";
import { collectionsApi } from "./api/collectionsApi";
import { favoritesApi } from "./api/favoritesApi";
import { legalPagesApi } from "./api/legalPagesApi";
import { locationApi } from "./api/locationApi";
import { orderApi } from "./api/orderApi";
import { mediaApi } from "./api/bannersApi";
import { reviewsApi } from "./api/reviewApi";
import { profileApi } from "./api/myProfileApi";
import { contactApi } from "./api/contactUs";
import { filtersApi } from "./api/filtersApi";
const store = configureStore({
reducer: {
[baseApi.reducerPath]: baseApi.reducer,
[categoriesApi.reducerPath]: categoriesApi.reducer,
[searchApi.reducerPath]: searchApi.reducer,
[cartApi.reducerPath]: cartApi.reducer,
[brandsApi.reducerPath]: brandsApi.reducer,
[collectionsApi.reducerPath]: collectionsApi.reducer,
[favoritesApi.reducerPath]: favoritesApi.reducer,
[legalPagesApi.reducerPath]: legalPagesApi.reducer,
[locationApi.reducerPath]: locationApi.reducer,
[orderApi.reducerPath]: orderApi.reducer,
[mediaApi.reducerPath]: mediaApi.reducer,
[reviewsApi.reducerPath]: reviewsApi.reducer,
[profileApi.reducerPath]: profileApi.reducer,
[contactApi.reducerPath]: contactApi.reducer,
[filtersApi.reducerPath]: filtersApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
baseApi.middleware,
categoriesApi.middleware,
searchApi.middleware,
brandsApi.middleware,
collectionsApi.middleware,
favoritesApi.middleware,
legalPagesApi.middleware,
locationApi.middleware,
orderApi.middleware,
reviewsApi.middleware,
mediaApi.middleware,
profileApi.middleware,
contactApi.middleware,
filtersApi.middleware
),
getDefaultMiddleware().concat(baseApi.middleware),
});
export default store;

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/door.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/engine.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/floor.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
src/assets/hood.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/maincar.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/roof.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/trunk_flor.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/trunk_lid.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

@@ -1,197 +1,152 @@
import React, { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import {
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>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,77 +1,116 @@
// DropdownMenu.module.scss
.dropdownContainer {
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; }
}

View File

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

View File

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

View File

@@ -39,11 +39,8 @@ const handleOptionChange = (e) => {
const sortValue = displayToSortMap[displayValue] || "none";
setSelectedOption(displayValue);
console.log('uuuess')
if (onPriceSortChange) {
onPriceSortChange(sortValue);
console.log('aaaaa')
}
};

View File

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

View File

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

View File

@@ -2,31 +2,16 @@ import React from "react";
import { Home, ShoppingBag, ShoppingCart, Heart, User } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import styles from "./FooterBar.module.scss";
import { useGetCartQuery } from "../../app/api/cartApi";
import { useCart } from "../../app/api/useCart";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useTranslation } from "react-i18next";
const FooterBar = () => {
const location = useLocation();
const { t } = useTranslation();
const { data: cartData } = useGetCartQuery();
const { cartCount } = useCart();
const { data: favoriteData } = useGetFavoritesQuery();
// FIX: Object içindeki tüm channel'ların item'larını birleştir
const getCartCount = () => {
if (!cartData?.data || typeof cartData.data !== 'object') {
return 0;
}
// Object.values ile tüm channel array'lerini al
const allCartItems = Object.values(cartData.data).flat();
return allCartItems.reduce((total, item) => {
return total + (parseInt(item.product_quantity, 10) || 0);
}, 0);
};
const cartCount = getCartCount();
const favoriteCount = favoriteData?.length || 0;
const navItems = [
@@ -88,4 +73,4 @@ const FooterBar = () => {
);
};
export default FooterBar;
export default FooterBar;

View File

@@ -59,8 +59,8 @@ const Footer = () => {
</a>
</li>
<li>
<a href="mailto:mm.marketplace.tm@gmail.com">
E-mail: mm.marketplace.tm@gmail.com
<a href="mailto:info@mm.com.tm">
E-mail: info@mm.com.tm
</a>
</li>
<li>
@@ -94,7 +94,6 @@ const Footer = () => {
/>
</a>
</div>
<img src={apk} alt="Download APK" className={styles.appLogo} />
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -199,6 +199,7 @@ export const OrderIcon = () => (
xmlns="http://www.w3.org/2000/svg"
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>

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -17,7 +17,7 @@ import { CiLocationOn } from "react-icons/ci";
import Sidebar from "../CategorySideBar";
import { useTranslation } from "react-i18next";
import { useSearchProductQuery } from "../../app/api/searchApi";
import { useGetCartQuery } from "../../app/api/cartApi";
import { useCart } from "../../app/api/useCart";
import { useGetOrdersQuery } from "../../app/api/orderApi";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useAuth } from "../../context/authContext";
@@ -31,27 +31,11 @@ const NavbarDown = () => {
const { data: searchData, refetch } = useSearchProductQuery(searchQuery, {
skip: !searchQuery,
});
const { data: cartData } = useGetCartQuery(undefined, {
refetchOnMountOrArgChange: false,
});
const { cartCount: cartItemCount } = useCart();
const { isAuthenticated, logout } = useAuth();
// FIX: Object içindeki tüm channel'ların item'larını birleştir
const getCartItemCount = () => {
if (!cartData?.data || typeof cartData.data !== 'object') {
return 0;
}
// Object.values ile tüm channel array'lerini al ve flat ile birleştir
const allCartItems = Object.values(cartData.data).flat();
return allCartItems.reduce((total, item) => {
return total + (parseInt(item.product_quantity, 10) || 0);
}, 0);
};
const cartItemCount = getCartItemCount();
const { data: ordersData } = useGetOrdersQuery();
const ordersItemCount = ordersData?.length || 0;
@@ -151,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>
@@ -167,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
@@ -230,7 +223,7 @@ const NavbarDown = () => {
count={ordersItemCount}
offset={[10, 0]}
>
<button className={styles.navButton}>
<button className={styles.navButton} >
<OrderIcon />
</button>
</Badge>
@@ -271,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}>
@@ -305,4 +301,4 @@ const NavbarDown = () => {
);
};
export default NavbarDown;
export default NavbarDown;

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,14 @@
border-radius: 8px;
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;
}

View File

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

View File

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

View File

@@ -3,35 +3,33 @@ 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;
};
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const ProductCard = ({
product,
showAddToCart = true,
@@ -39,79 +37,80 @@ 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
);
// ✅ Sadece cache'den oku, yeni request gönderme
const { data: cartData } = useGetCartQuery(undefined, {
selectFromResult: (result) => ({
data: result.data,
}),
refetchOnMountOrArgChange: false, // ✅ Mount'ta yeniden çağırma
refetchOnFocus: false,
refetchOnReconnect: false,
});
const { getCartItem } = useCart();
const [addToCart] = useAddToCartMutation();
const [updateCartItem] = useUpdateCartItemMutation();
const [removeFromCart] = useRemoveFromCartMutation();
// ✅ Cart data'yı düzgün parse et
const getCartItem = () => {
if (!cartData || typeof cartData !== "object") {
return null;
}
// Eğer data grouped object ise (store bazlı)
const allCartItems = Object.values(cartData).flat();
return allCartItems.find(
(item) =>
item.product?.id === product.id || item.product_id === product.id
);
};
const cartItem = getCartItem();
const cartItem = getCartItem(product.id);
const [localQuantity, setLocalQuantity] = useState(0);
const [pendingQuantity, setPendingQuantity] = useState(0);
// ✅ Cart item değiştiğinde local state'i güncelle
useEffect(() => {
if (cartItem) {
const qty = cartItem.quantity || cartItem.product_quantity || 0;
setLocalQuantity(qty);
setPendingQuantity(qty);
} else {
setLocalQuantity(0);
setPendingQuantity(0);
}
}, [cartItem]); // ✅ Sadece cartItem değişince, cartData değil
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);
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();
@@ -121,57 +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 updateCart = async () => {
const currentCartQty =
cartItem?.quantity || cartItem?.product_quantity || 0;
if (pendingQuantity !== currentCartQty && pendingQuantity > 0) {
try {
setIsLoading(true);
await updateCartItem({
productId: product.id,
quantity: pendingQuantity,
}).unwrap();
// ✅ RTK Query invalidatesTags ile otomatik güncellenecek
} catch (error) {
console.error("Failed to update cart item:", error);
// ✅ Hata varsa önceki değere dön
setLocalQuantity(currentCartQty);
setPendingQuantity(currentCartQty);
} finally {
setIsLoading(false);
}
}
};
const debouncedUpdate = debounce(updateCart, 300);
const currentCartQty =
cartItem?.quantity || cartItem?.product_quantity || 0;
if (pendingQuantity !== currentCartQty) {
debouncedUpdate();
}
return () => debouncedUpdate.cancel();
}, [pendingQuantity, cartItem, product.id, updateCartItem]);
const handleQuantityIncrease = (event) => {
event.preventDefault();
event.stopPropagation();
@@ -194,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);
@@ -225,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
@@ -290,6 +244,7 @@ const ProductCard = ({
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
@@ -351,4 +306,4 @@ const ProductCard = ({
);
};
export default ProductCard;
export default ProductCard;

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
export default {
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",
@@ -140,6 +155,7 @@ export default {
photo: "Photo",
brand: "Brand",
code: "Code",
channel: "Store",
quantity: "Quantity",
price: "Price",
total: "Total",
@@ -156,6 +172,7 @@ export default {
category: {
total: "Total",
items: "items",
filter: "Filters",
subCategories: "SubCategories",
order: "Order",
notSelected: "Not Selected",
@@ -167,11 +184,28 @@ export default {
neverMind: "Default",
From_expensive_to_cheap: "From expensive to cheap",
From_cheap_to_expensive: "From cheap to expensive",
},
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",
@@ -188,10 +222,13 @@ export default {
TermsofUseandPrivacyPolicy: "Terms of Use and Privacy Policy",
mobile_applications: "Mobile applications",
copyright: " All rights reserved.",
about_paragraph1: "Our Marketplace is a convenient online marketplace where you'll find everything in one place, from auto parts and electronics to home goods and fresh produce. We've been in business since 2019, and in that time we've collected hundreds of trusted brands so you can choose only the best. The range is constantly growing - we keep a close eye on your requests and always try to offer more.",
about_paragraph2: "Our mission is to make shopping easy and convenient. Everything you need can now be ordered in a couple of clicks from the comfort of your own home. You save time, effort and money - and we make sure that everything arrives quickly and hassle-free.",
about_paragraph3: "You can pay for the order as you like: cash or bank card upon receipt.",
about_paragraph4: "We are always open to co-operation and welcome feedback. Do you have an idea, question or suggestion? Write to us - we will be happy to answer!"
about_paragraph1:
"Our Marketplace is a convenient online marketplace where you'll find everything in one place, from auto parts and electronics to home goods and fresh produce. We've been in business since 2019, and in that time we've collected hundreds of trusted brands so you can choose only the best. The range is constantly growing - we keep a close eye on your requests and always try to offer more.",
about_paragraph2:
"Our mission is to make shopping easy and convenient. Everything you need can now be ordered in a couple of clicks from the comfort of your own home. You save time, effort and money - and we make sure that everything arrives quickly and hassle-free.",
about_paragraph3:
"You can pay for the order as you like: cash or bank card upon receipt.",
about_paragraph4:
"We are always open to co-operation and welcome feedback. Do you have an idea, question or suggestion? Write to us - we will be happy to answer!",
},
};

View File

@@ -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: "Способ оплаты",
@@ -125,7 +138,8 @@ export default {
verify: "Верификация",
name: "Имя",
address: "Address",
lastname:"Фамилия"
lastname: "Фамилия",
seller_panel: "Панель продавца",
},
order: {
orderDate: "Дата заказа",
@@ -137,6 +151,7 @@ export default {
photo: "Фото",
brand: "Бренд",
code: "Код",
channel: "Магазин",
quantity: "Количество",
price: "Цена",
total: "Итого",
@@ -153,6 +168,7 @@ export default {
category: {
total: "Всего",
items: "товаров",
filter: "Фильтры",
subCategories: "Подкатегории",
order: "Сортировка",
notSelected: "Не выбрано",
@@ -164,11 +180,28 @@ export default {
neverMind: "По умолчанию",
From_expensive_to_cheap: "От дорогих к дешевым",
From_cheap_to_expensive: "От дешевых к дорогим",
},
price: "Цена",
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: "Избранные",
@@ -187,9 +220,13 @@ export default {
"Условия использования и политика конфиденциальности",
mobile_applications: "Мобильные приложения",
copyright: "Все права защищены.",
about_paragraph1: "Наш маркетплейс — это удобная онлайн-площадка, где вы найдёте всё в одном месте: от автозапчастей и электроники до товаров для дома и свежих продуктов. Мы работаем с 2019 года и за это время собрали сотни надёжных брендов, чтобы вы могли выбирать только лучшее. Ассортимент постоянно растёт — мы внимательно следим за вашими запросами и всегда стараемся предложить больше.",
about_paragraph2: "Наша миссия — сделать покупки простыми и удобными. Всё, что вам нужно, теперь можно заказать в пару кликов, не выходя из дома. Вы экономите время, силы и деньги — а мы заботимся о том, чтобы всё приехало быстро и без лишних хлопот.",
about_paragraph3: "Оплатить заказ можно как вам удобно: наличными или банковской картой при получении.",
about_paragraph4: "Мы всегда открыты к сотрудничеству и рады обратной связи. Есть идея, вопрос или предложение? Напишите нам — мы с удовольствием ответим!"
about_paragraph1:
"Наш маркетплейс — это удобная онлайн-площадка, где вы найдёте всё в одном месте: от автозапчастей и электроники до товаров для дома и свежих продуктов. Мы работаем с 2019 года и за это время собрали сотни надёжных брендов, чтобы вы могли выбирать только лучшее. Ассортимент постоянно растёт — мы внимательно следим за вашими запросами и всегда стараемся предложить больше.",
about_paragraph2:
"Наша миссия — сделать покупки простыми и удобными. Всё, что вам нужно, теперь можно заказать в пару кликов, не выходя из дома. Вы экономите время, силы и деньги — а мы заботимся о том, чтобы всё приехало быстро и без лишних хлопот.",
about_paragraph3:
"Оплатить заказ можно как вам удобно: наличными или банковской картой при получении.",
about_paragraph4:
"Мы всегда открыты к сотрудничеству и рады обратной связи. Есть идея, вопрос или предложение? Напишите нам — мы с удовольствием ответим!",
},
};

View File

@@ -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",
@@ -128,7 +141,8 @@ export default {
verify: "Tassykla",
name: "Ady",
address: "Salgy",
lastname:"Familýaňyz"
lastname: "Familýaňyz",
seller_panel: "Satyjy paneli",
},
order: {
orderDate: "Sargyt senesi",
@@ -141,6 +155,7 @@ export default {
brand: "Brend",
code: "Kody",
quantity: "Sany",
channel: "Magazin",
price: "Bahasy",
total: "Jemi",
orderNumber: "Sargyt belgisi",
@@ -156,6 +171,7 @@ export default {
category: {
total: "Jemi",
items: "haryt",
filter: "Süzgüç",
subCategories: "Içki kategoriýalar",
order: "Tertip",
notSelected: "Saýlanmadyk",
@@ -167,11 +183,28 @@ export default {
neverMind: "Sortlanmadyk",
From_expensive_to_cheap: "Gymmatdan arzana",
From_cheap_to_expensive: "Arzandan gymmada",
},
price: "Bahasy",
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",
@@ -189,10 +222,13 @@ export default {
TermsofUseandPrivacyPolicy: "Ulanyş düzgünleri we gizlinlik syýasaty",
mobile_applications: "Mobile goşundylar",
copyright: "Ähli hukuklar goralan.",
about_paragraph1: "Biziň bazarymyz amatly onlaýn platforma bolup, ol ýerde hemme zady bir ýerde tapyp bilersiňiz: awtoulag zapas şaýlaryndan we elektronikadan başlap, öý önümlerine we täze önümlere çenli. 2019-njy ýyldan bäri işleýäris we bu döwürde diňe gowularyny saýlap bilersiňiz diýip, ýüzlerçe ygtybarly marka ýygnadyk. Aralygy yzygiderli ösýär - islegleriňize ýakyndan gözegçilik edýäris we elmydama has köp zat hödürlemäge synanyşýarys..",
about_paragraph2: "Biziň wezipämiz, söwda etmegi ýönekeý we amatly etmek. Gerek zatlaryň hemmesini indi öýüňizden çykman iki gezek basyp sargyt edip bilersiňiz. Wagt, güýç we pul tygşytlaýarsyňyz - we hemme zadyň çalt we gereksiz kynçylyksyz gelýändigine göz ýetirýäris.",
about_paragraph3: "Sargydyňyzy özüňize amatly görnüşde töläp bilersiňiz: nagt ýa-da alandan soň kredit kartoçkasy bilen.",
about_paragraph4: "Hyzmatdaşlyga elmydama açyk we pikirleri kabul edýäris. Pikiriňiz, soragyňyz ýa-da teklibiňiz barmy? Bize ýazyň - jogap bermäge şat bolarys!"
about_paragraph1:
"Biziň bazarymyz amatly onlaýn platforma bolup, ol ýerde hemme zady bir ýerde tapyp bilersiňiz: awtoulag zapas şaýlaryndan we elektronikadan başlap, öý önümlerine we täze önümlere çenli. 2019-njy ýyldan bäri işleýäris we bu döwürde diňe gowularyny saýlap bilersiňiz diýip, ýüzlerçe ygtybarly marka ýygnadyk. Aralygy yzygiderli ösýär - islegleriňize ýakyndan gözegçilik edýäris we elmydama has köp zat hödürlemäge synanyşýarys..",
about_paragraph2:
"Biziň wezipämiz, söwda etmegi ýönekeý we amatly etmek. Gerek zatlaryň hemmesini indi öýüňizden çykman iki gezek basyp sargyt edip bilersiňiz. Wagt, güýç we pul tygşytlaýarsyňyz - we hemme zadyň çalt we gereksiz kynçylyksyz gelýändigine göz ýetirýäris.",
about_paragraph3:
"Sargydyňyzy özüňize amatly görnüşde töläp bilersiňiz: nagt ýa-da alandan soň kredit kartoçkasy bilen.",
about_paragraph4:
"Hyzmatdaşlyga elmydama açyk we pikirleri kabul edýäris. Pikiriňiz, soragyňyz ýa-da teklibiňiz barmy? Bize ýazyň - jogap bermäge şat bolarys!",
},
};

View File

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

View File

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

View File

@@ -16,10 +16,9 @@
.cartHeader {
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;
}
}
}
}
}

View File

@@ -2,24 +2,22 @@ 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,
} from "../../app/api/cartApi";
import { useCart } from "../../app/api/useCart";
import { DecreaseIcon, IncreaseIcon } from "../../components/Icons";
import { debounce } from "lodash";
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,9 +30,7 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
<div className={styles.truncatedDescription}>
<div
dangerouslySetInnerHTML={{
__html: isExpanded
? description
: shouldTruncate
__html: shouldTruncate
? description.substring(0, maxLength) + "..."
: description,
}}
@@ -44,37 +40,16 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
};
const CartPage = () => {
const {
data: response = {},
error,
isError,
isLoading,
} = useGetCartQuery(undefined, {
refetchOnMountOrArgChange: 30, // ✅ Sadece 30 saniye sonra mount'ta refetch
refetchOnFocus: false,
refetchOnReconnect: false,
});
const { cartData, cartItems, isLoading } = useCart();
const { t } = useTranslation();
// Handle the new data structure - data is now an object grouped by store
const cartData = isError ? {} : (response.data || {});
// Convert object of arrays to flat array for backward compatibility
const cartItems = useMemo(() => {
return Object.values(cartData).flat();
}, [cartData]);
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({});
@@ -87,202 +62,130 @@ 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
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
};
}).filter(Boolean);
return Object.entries(cartData)
.map(([storeSlug, items]) => {
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,
};
})
.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 update - tek bir useEffect
useEffect(() => {
const debouncedUpdates = {};
const timers = {};
const updateItem = async (productId) => {
const serverItem = cartItems.find((item) => item.product.id === productId);
const serverQuantity = serverItem ? parseInt(serverItem.product_quantity, 10) : 0;
const pendingQuantity = pendingQuantities[productId];
// ✅ Eğer değişiklik yoksa, güncelleme yapma
if (pendingQuantity === undefined || pendingQuantity === serverQuantity) {
return;
}
try {
setLoadingItems((prev) => ({ ...prev, [productId]: true }));
if (pendingQuantity <= 0) {
await removeFromCart({ productId }).unwrap();
} else {
await updateCartItem({
productId,
quantity: pendingQuantity,
}).unwrap();
}
// ✅ RTK Query otomatik cache'i güncelleyecek, refetch'e gerek yok
} catch (error) {
console.error("Failed to update cart:", error);
// ✅ Hata durumunda geri al
const originalItem = cartItems.find(
(item) => item.product.id === productId
);
if (originalItem) {
const originalQty = parseInt(originalItem.product_quantity, 10) || 0;
setLocalQuantities((prev) => ({
...prev,
[productId]: originalQty,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: originalQty,
}));
}
} finally {
setLoadingItems((prev) => ({ ...prev, [productId]: false }));
}
};
// ✅ Her productId için debounced update oluştur
Object.keys(pendingQuantities).forEach((productId) => {
if (!debouncedUpdates[productId]) {
debouncedUpdates[productId] = debounce(
() => updateItem(productId),
500 // ✅ 500ms debounce (daha stabil)
);
}
debouncedUpdates[productId]();
const serverItem = cartItems.find(
(item) => String(item.product.id) === String(productId),
);
const serverQty = serverItem
? parseInt(serverItem.product_quantity, 10)
: 0;
const pendingQty = pendingQuantities[productId];
if (
pendingQty === undefined ||
pendingQty === serverQty ||
pendingQty <= 0
)
return;
timers[productId] = setTimeout(async () => {
try {
setLoadingItems((prev) => ({ ...prev, [productId]: true }));
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(debouncedUpdates).forEach((debouncedFn) =>
debouncedFn.cancel()
);
};
}, [pendingQuantities, cartItems, updateCartItem, removeFromCart]);
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 handleCheckout = (storeId) =>
setCheckoutStores((prev) => ({ ...prev, [storeId]: true }));
const handleBackToCart = (storeId) =>
setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
const getStoreShippingPrice = (store) => {
return store.shipping_price !== null && store.shipping_price !== undefined
? parseFloat(store.shipping_price)
: 20;
};
const handleCheckout = (storeId) => {
setCheckoutStores(prev => ({ ...prev, [storeId]: true }));
};
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);
@@ -292,50 +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
0,
);
};
return (
<div className={styles.cartContainer}>
@@ -375,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}>
@@ -397,14 +290,14 @@ const CartPage = () => {
<Checkout
cartItems={store.items}
shippingPrice={shippingPrice}
productIds={store.items.map(item => item.product.id)}
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;
}}
/>
) : (
<div style={{background:"white", width: "100%"}}>
<div style={{ background: "white", width: "100%" }}>
<div className={styles.storeHeader}>
<h3>{store.name}</h3>
</div>
@@ -427,23 +320,31 @@ 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
onClick={handleQuantityDecrease(item.product.id)}
onClick={handleQuantityDecrease(
item.product.id,
)}
className={styles.quantityBtn}
disabled={loadingItems[item.product.id]}
>
<DecreaseIcon />
</button>
<span>
{localQuantities[item.product.id] !== undefined
{localQuantities[item.product.id] !==
undefined
? localQuantities[item.product.id]
: parseInt(item.product_quantity, 10) || 0}
: parseInt(item.product_quantity, 10) ||
0}
</span>
<button
onClick={handleQuantityIncrease(item.product.id)}
onClick={handleQuantityIncrease(
item.product.id,
)}
className={styles.quantityBtn}
disabled={loadingItems[item.product.id]}
>
@@ -454,7 +355,9 @@ const CartPage = () => {
<div className={styles.deleteBtnContainer}>
<button
className={styles.deleteBtn}
onClick={() => showDeleteConfirm(item.product.id)}
onClick={() =>
showDeleteConfirm(item.product.id)
}
>
<FaTrashAlt />
</button>
@@ -466,34 +369,49 @@ 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>
<h3>
{store.name} - {t("cart.basket")}:
</h3>
{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] ? t("cart.order") : t("cart.prepareOrders")}
{checkoutStores[store.id]
? t("cart.order")
: t("cart.prepareOrders")}
</button>
</div>
</div>
);
})}
</div>
{/* Mobile sticky summary */}
{/* <div className={styles.container}>
<div className={styles.summaryCard} ref={expandedRef}>
@@ -547,4 +465,4 @@ const CartPage = () => {
);
};
export default CartPage;
export default CartPage;

View File

@@ -1,3 +1,277 @@
.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;
flex-direction: column;
gap: 0;
}
// Price Filter Styles
.priceFilterContainer {
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 8px;
margin-bottom: 16px;
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: 6px;
flex: 1;
min-width: 0;
}
.priceLabel {
font-size: 12px;
color: #666;
font-weight: 600;
letter-spacing: 0.3px;
}
.priceInput {
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: #fff;
transition: all 0.2s ease;
width: 85%;
&::placeholder {
color: #bbb;
}
}
.priceInput:focus {
border-color: #d32824;
box-shadow: 0 0 0 3px rgba(211, 40, 36, 0.1);
outline: none;
}
.priceDivider {
display: none;
}
.filtersContainer{
.filterSection {
margin-bottom: 20px;
display: flex;
flex-direction: column;
h3 {
margin-bottom: 10px;
font-size: 18px;
font-weight: 600;
color: #000000;
}
ul {
list-style: none;
padding: 0;
li {
margin-bottom: 8px;
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 {
display: flex;
align-items: center;
gap: 8px;
}
}
input[type="text"] {
width: auto;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
input[type="radio"] {
display: none;
}
.customRadio {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #d1d5db;
border-radius: 50%;
margin-right: 8px;
background-color: #d1d5db;
transition:
background-color 0.2s,
border-color 0.2s;
}
input[type="radio"]:checked + .customRadio {
background-color: #888888;
}
input[type="checkbox"] {
display: none;
}
input[type="checkbox"] {
display: none;
}
.customCheckbox {
width: 18px;
height: 18px;
margin-right: 8px;
border-radius: 4px;
background-color: #d1d5db;
position: relative;
transition:
background-color 0.2s,
border-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.checkIcon {
display: none;
width: 18px;
height: 18px;
fill: #888888;
}
input[type="checkbox"]:checked + .customCheckbox {
background-color: #d1d5db;
}
input[type="checkbox"]:checked + .customCheckbox .checkIcon {
display: block;
}
}
}
.categoryPage {
display: flex;
gap: 10px;
@@ -35,42 +309,35 @@
}
}
.bars {
display: flex;
gap: 10px;
justify-content: flex-end;
border-bottom: 1px solid #d1d5db;
border-top: 1px solid #d1d5db;
padding: 8px 0;
@media screen and (min-width: 1024px) {
display: none;
}
.sum {
color: #6b7280;
font-size: 12px;
text-align: left;
background-color: transparent;
border: 1px solid #6b7280;
padding: 3px 6px;
display: block;
border-radius: 0.5rem;
margin: 0;
.filterButton {
position: fixed;
bottom: 80px;
right: 20px;
z-index: 1000;
background-color: #d32824;
border-radius: 12px;
font-size: 16px;
border: none;
padding: 10px 24px;
font-weight: 700;
color: #ffffff;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
svg {
font-size: 20px;
}
&:active {
transform: scale(0.95);
}
}
// button {
// background-color: #ec6323;
// border-radius: 0.5rem;
// font-size: 14px;
// border: none;
// padding: 11px;
// font-weight: 600;
// color: #ffffffff;
// display: flex;
// align-items: center;
// gap: 5px;
// img {
// width: 16px;
// height: 16px;
// }
// }
}
.subCategories {
display: flex;
@@ -100,15 +367,18 @@
gap: 20px;
margin-bottom: 15px;
}
aside {
.sidebar {
width: 250px;
position: sticky;
top: 5rem;
background-color: #ffff;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
overflow-y: auto;
height: calc(-8.25rem + 100vh);
@media screen and (max-width: 1280px) {
width: 200px;
}
@@ -124,98 +394,7 @@
font-size: 24px;
}
.filterSection {
margin-bottom: 20px;
display: flex;
flex-direction: column;
h3 {
margin-bottom: 10px;
font-size: 18px;
font-weight: 600;
color: #000000;
}
ul {
list-style: none;
padding: 0;
li {
margin-bottom: 8px;
color: #000000;
font-size: 14px;
cursor: pointer;
}
label {
display: flex;
align-items: center;
gap: 8px;
}
}
input[type="text"] {
width: auto;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
input[type="radio"] {
display: none;
}
.customRadio {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #d1d5db;
border-radius: 50%;
margin-right: 8px;
background-color: #d1d5db;
transition: background-color 0.2s, border-color 0.2s;
}
input[type="radio"]:checked + .customRadio {
background-color: #888888;
}
input[type="checkbox"] {
display: none;
}
input[type="checkbox"] {
display: none;
}
.customCheckbox {
width: 18px;
height: 18px;
margin-right: 8px;
border-radius: 4px;
background-color: #d1d5db;
position: relative;
transition: background-color 0.2s, border-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.checkIcon {
display: none;
width: 18px;
height: 18px;
fill: #888888;
}
input[type="checkbox"]:checked + .customCheckbox {
background-color: #d1d5db;
}
input[type="checkbox"]:checked + .customCheckbox .checkIcon {
display: block;
}
}
&::-webkit-scrollbar {
width: 6px;
}
@@ -230,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;
@@ -312,3 +499,45 @@
}
}
}
.channelHeader {
display: flex;
align-items: center;
gap: 20px;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
.channelLogo {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #eee;
}
.channelInfo {
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #333;
}
}
@media screen and (max-width: 768px) {
padding: 15px;
gap: 15px;
.channelLogo {
width: 60px;
height: 60px;
}
.channelInfo h1 {
font-size: 20px;
}
}
}

View File

@@ -1,6 +1,7 @@
import React from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { TiTick } from "react-icons/ti";
import { Divider } from "antd";
import styles from "../CategoryPage.module.scss";
const CategoryFilters = ({
@@ -9,18 +10,50 @@ const CategoryFilters = ({
selectedFilterBrand,
brandSearchQuery,
searchQuery,
minPrice,
maxPrice,
onMinPriceChange,
onMaxPriceChange,
onCategorySelect,
onCategoryDeselect,
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 (
<aside className={styles.sidebar}>
<aside className={`${styles.filtersContainer} ${className}`}>
{filtersData?.categories?.length > 0 && (
<div className={styles.filterSection}>
<h3>{t("category.subCategories")}</h3>
@@ -65,7 +98,7 @@ const CategoryFilters = ({
.filter((brand) =>
brand.name
.toLowerCase()
.includes(brandSearchQuery.toLowerCase())
.includes(brandSearchQuery.toLowerCase()),
)
.map((brand) => (
<li key={brand.id}>
@@ -91,6 +124,72 @@ const CategoryFilters = ({
</ul>
</div>
)}
<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>
<input
type="number"
min="0"
placeholder={t("category.minPrice")}
value={minPrice}
onChange={(e) => onMinPriceChange(e.target.value)}
className={styles.priceInput}
/>
</div>
<span className={styles.priceDivider}>-</span>
<div className={styles.priceInputGroup}>
<span className={styles.priceLabel}>{t("category.maxPrice")}</span>
<input
type="number"
min="0"
placeholder={t("category.maxPrice")}
value={maxPrice}
onChange={(e) => onMaxPriceChange(e.target.value)}
className={styles.priceInput}
/>
</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>
);
};

View File

@@ -0,0 +1,324 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Modal } from "antd";
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
import { FaShoppingCart } from "react-icons/fa";
import { DecreaseIcon, IncreaseIcon } from "../../../components/Icons";
import {
useAddFavoriteMutation,
useRemoveFavoriteMutation,
useGetFavoritesQuery,
} from "../../../app/api/favoritesApi";
import {
useAddToCartMutation,
useUpdateCartItemMutation,
useRemoveFromCartMutation,
} from "../../../app/api/cartApi";
import { useCart } from "../../../app/api/useCart";
import styles from "./Mobilephonecard.module.scss";
/**
* Parses product.description HTML into spec pairs.
* Format inside HTML: "Label1: Value1; Label2: Value2; ..."
*/
const parseSpecs = (htmlString) => {
if (!htmlString) return [];
const div = document.createElement("div");
div.innerHTML = htmlString;
let processedHtml = htmlString
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/strong>/gi, ": ");
div.innerHTML = processedHtml;
const text = (div.textContent || div.innerText || "").trim();
return text
.split(/[;\n]+/)
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk) => {
const separatorIdx = chunk.search(/[:|]/);
if (separatorIdx === -1) return null;
return {
label: chunk.slice(0, separatorIdx).trim(),
value: chunk.slice(separatorIdx + 1).trim(),
};
})
.filter((item) => item !== null && item.value !== "");
};
const MobilePhoneCard = ({
product,
showAddToCart = true,
showFavoriteButton = true,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// ── Favorites ──────────────────────────────────────────────────────────────
const [addFavorite] = useAddFavoriteMutation();
const [removeFavorite] = useRemoveFavoriteMutation();
const { data: favoriteProducts = [] } = useGetFavoritesQuery();
const [localIsFavorite, setLocalIsFavorite] = useState(false);
useEffect(() => {
if (Array.isArray(favoriteProducts)) {
setLocalIsFavorite(
favoriteProducts.some((fav) => fav.product?.id === product.id)
);
}
}, [favoriteProducts, product.id]);
// ── Cart ───────────────────────────────────────────────────────────────────
const { getCartItem } = useCart();
const [addToCart] = useAddToCartMutation();
const [updateCartItem] = useUpdateCartItemMutation();
const [removeFromCart] = useRemoveFromCartMutation();
const cartItem = getCartItem(product.id);
const [localQuantity, setLocalQuantity] = useState(0);
const [pendingQuantity, setPendingQuantity] = useState(0);
useEffect(() => {
const qty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0,
10
);
setLocalQuantity(qty);
setPendingQuantity(qty);
}, [cartItem]);
// Debounced sync to server
useEffect(() => {
const serverQty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0,
10
);
if (pendingQuantity === serverQty || pendingQuantity <= 0) return;
const timer = setTimeout(async () => {
try {
setIsLoading(true);
await updateCartItem({
productId: product.id,
quantity: pendingQuantity,
}).unwrap();
} catch {
setLocalQuantity(serverQty);
setPendingQuantity(serverQty);
} finally {
setIsLoading(false);
}
}, 500);
return () => clearTimeout(timer);
}, [pendingQuantity, cartItem, product.id, updateCartItem]);
// ── Handlers ───────────────────────────────────────────────────────────────
const handleCardClick = () => navigate(`/product/${product.id}`);
const handleAddToCart = async (e) => {
e.preventDefault();
e.stopPropagation();
if (product.stock <= 0) {
setStockErrorModalVisible(true);
return;
}
setLocalQuantity((p) => p + 1);
setPendingQuantity((p) => p + 1);
try {
await addToCart({ productId: product.id, quantity: 1 }).unwrap();
} catch {
setLocalQuantity((p) => p - 1);
setPendingQuantity((p) => p - 1);
}
};
const handleIncrease = (e) => {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
if (localQuantity >= product.stock) {
setStockErrorModalVisible(true);
return;
}
setLocalQuantity((p) => p + 1);
setPendingQuantity((p) => p + 1);
};
const handleDecrease = (e) => {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
if (pendingQuantity <= 1) {
setLocalQuantity(0);
setPendingQuantity(0);
setIsLoading(true);
removeFromCart({ productId: product.id })
.unwrap()
.catch(() => {
setLocalQuantity(1);
setPendingQuantity(1);
})
.finally(() => setIsLoading(false));
} else {
setLocalQuantity((p) => p - 1);
setPendingQuantity((p) => p - 1);
}
};
const handleToggleFavorite = async (e) => {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
setIsLoading(true);
setLocalIsFavorite((prev) => !prev);
try {
if (localIsFavorite) await removeFavorite(product.id).unwrap();
else await addFavorite(product.id).unwrap();
} catch {
setLocalIsFavorite((prev) => !prev);
} finally {
setIsLoading(false);
}
};
// ── Derived ────────────────────────────────────────────────────────────────
const specs = parseSpecs(product.description);
const thumbnail =
product.media?.[0]?.images_400x400 || product.media?.[0]?.thumbnail;
const hasDiscount =
product.old_price_amount &&
parseFloat(product.old_price_amount) > parseFloat(product.price_amount);
return (
<>
<div className={styles.card} onClick={handleCardClick}>
{/* Image */}
<div className={styles.imageCol}>
{product.stock === 0 && (
<span className={styles.outOfStockBadge}>
{t("common.out_of_stock")}
</span>
)}
{thumbnail ? (
<img src={thumbnail} alt={product.name} className={styles.image} />
) : (
<div className={styles.imagePlaceholder}>📱</div>
)}
</div>
{/* Info */}
<div className={styles.infoCol}>
<div className={styles.titleRow}>
<span className={styles.name}>{product.name}</span>
{product.brand?.name && (
<span className={styles.brand}>{product.brand.name}</span>
)}
</div>
{/* Dense spec paragraph — mirrors reference image */}
{specs.length > 0 && (
<p className={styles.specLine}>
{specs.map((s, i) => (
<span key={i}>
<span className={styles.specLabel}>{s.label}: </span>
<span className={styles.specValue}>{s.value}</span>
{i < specs.length - 1 && "; "}
</span>
))}
</p>
)}
{/* Footer */}
<div className={styles.footer} onClick={(e) => e.stopPropagation()}>
<div className={styles.priceBlock}>
<span className={styles.price}>{product.price_amount} m.</span>
{hasDiscount && (
<span className={styles.oldPrice}>
{product.old_price_amount} m.
</span>
)}
</div>
<div className={styles.actions}>
{showFavoriteButton && (
<button
className={`${styles.iconBtn} ${
localIsFavorite ? styles.favActive : ""
}`}
onClick={handleToggleFavorite}
disabled={isLoading}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart &&
(localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
className={styles.qtyBtn}
onClick={handleDecrease}
disabled={isLoading}
>
<DecreaseIcon />
</button>
<span className={styles.qtyValue}>{localQuantity}</span>
<button
className={styles.qtyBtn}
onClick={handleIncrease}
disabled={isLoading}
>
<IncreaseIcon />
</button>
</div>
) : (
<button
className={styles.cartBtn}
onClick={handleAddToCart}
disabled={isLoading || product.stock === 0}
>
<FaShoppingCart />
<span>Sebede goş</span>
</button>
))}
</div>
</div>
</div>
</div>
<Modal
title={t("common.warning")}
open={stockErrorModalVisible}
onOk={() => setStockErrorModalVisible(false)}
onCancel={() => setStockErrorModalVisible(false)}
footer={[
<button
key="ok"
onClick={() => setStockErrorModalVisible(false)}
className={styles.modalButton}
>
{t("common.ok")}
</button>,
]}
>
<p>
{t("common.not_enough_stock", {
available: product.stock,
requested: localQuantity + 1,
})}
</p>
</Modal>
</>
);
};
export const MOBILE_PHONE_CATEGORY_ID = 531;
export default MobilePhoneCard;

View File

@@ -0,0 +1,270 @@
// MobilePhoneCard.module.scss
// Mimics the dense spec-row layout from the reference image (e.g. DNS/Citilink product list)
.card {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
background: #fff;
cursor: pointer;
transition: background 0.15s ease;
&:first-child {
border-top: 1px solid #e8e8e8;
}
&:hover {
background: #f5f8ff;
}
}
// ── Image column ─────────────────────────────────────────────
.imageCol {
position: relative;
flex-shrink: 0;
width: 250px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 4px;
}
.image {
width: 250px;
height: 260px;
object-fit: contain;
}
.imagePlaceholder {
width: 110px;
height: 130px;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
background: #f0f0f0;
border-radius: 4px;
}
.outOfStockBadge {
position: absolute;
top: 0;
left: 0;
background: #999;
color: #fff;
font-size: 10px;
padding: 2px 5px;
border-radius: 3px;
white-space: nowrap;
z-index: 1;
}
// ── Info column ──────────────────────────────────────────────
.infoCol {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.titleRow {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.name {
font-size: 14px;
font-weight: 600;
color: #0645ad;
cursor: pointer;
line-height: 1.3;
&:hover {
text-decoration: underline;
}
}
.brand {
font-size: 11px;
color: #888;
font-weight: 400;
white-space: nowrap;
}
// Dense spec paragraph — key selling point of this card
.specLine {
margin: 0;
font-size: 12px;
color: #444;
line-height: 1.6;
word-break: break-word;
}
.specLabel {
font-weight: 600;
color: #222;
}
.specValue {
font-weight: 400;
color: #555;
}
// ── Footer ───────────────────────────────────────────────────
.footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding-top: 6px;
border-top: 1px solid #f0f0f0;
}
.priceBlock {
display: flex;
align-items: baseline;
gap: 8px;
}
.price {
font-size: 24px;
font-weight: 700;
color: #c0392b;
}
.oldPrice {
font-size: 16px;
color: #aaa;
text-decoration: line-through;
}
// ── Actions ──────────────────────────────────────────────────
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.iconBtn {
background: none;
border: 1px solid #ddd;
border-radius: 6px;
width: 34px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #aaa;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: #888;
border-color: #888;
}
&.favActive {
color: #888;
border-color: #888;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cartBtn {
display: flex;
align-items: center;
gap: 6px;
padding-left: 0.5rem;
padding-right: 0.5rem;
background: #d32824;
color: #fff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
height: 36px;
&:hover {
background: #c0392b;
}
&:disabled {
background: #ccc;
cursor: not-allowed;
}
}
// ── Quantity controls ────────────────────────────────────────
.quantityControls {
display: flex;
align-items: center;
gap: 0;
border: 1px solid #d32824;
background-color: #d32824;
border-radius: 6px;
overflow: hidden;
min-width: 160px;
justify-content: space-between;
}
.qtyBtn {
background: none;
border: none;
width: 32px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
font-size: 16px;
transition: background 0.1s;
&:hover {
background: #e86064;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.qtyValue {
min-width: 28px;
text-align: center;
font-size: 14px;
font-weight: 600;
color: #fff;
padding: 0 4px;
line-height: 34px;
}
// ── Modal ────────────────────────────────────────────────────
.modalButton {
padding: 6px 20px;
background: #e74c3c;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
&:hover {
background: #c0392b;
}
}

View File

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

View File

@@ -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,292 +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}`,
].filter(Boolean);
return parts.join("|") || "none";
}, [
selectedFilterCategory,
categoryId,
brandId,
collectionId,
selectedFilterBrand,
]);
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,
@@ -314,3 +222,4 @@ const useCategoryProducts = ({
};
export default useCategoryProducts;

View File

@@ -1,49 +1,85 @@
"use client";
import { useEffect, useState, useMemo, useRef } from "react";
import { useParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Result, Button } from "antd";
import { Result, Button, Drawer } from "antd";
import InfiniteScroll from "react-infinite-scroll-component";
import { LuFilter } from "react-icons/lu";
import styles from "./CategoryPage.module.scss";
import ProductCard from "../../components/ProductCard/index";
import BrandSidebar from "../../components/BrandsSidebar/index";
import FilterSidebar from "../../components/FilterSideBar/index";
import Loader from "../../components/Loader/index";
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 routeKey = useMemo(
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}`,
[categoryId, collectionId, brandId]
);
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const prevRouteRef = useRef(routeKey);
const isInitialMount = useRef(true);
const searchResults = location.state?.searchData?.data || [];
const searchResults = useMemo(
() => location.state?.searchData?.data || [],
[location.state?.searchData?.data],
);
const searchQuery = location.state?.searchQuery || null;
const {
@@ -52,6 +88,7 @@ const CategoryPage = () => {
isSubCategory,
filtersData,
collectionData,
channelData,
isLoading: dataLoading,
hasError: dataError,
fetchFilters,
@@ -59,6 +96,7 @@ const CategoryPage = () => {
categoryId,
collectionId,
brandId,
channelId,
selectedFilterCategory: filterState.selectedFilterCategory,
searchQuery,
});
@@ -72,6 +110,7 @@ const CategoryPage = () => {
} = useCategoryProducts({
categoryId,
collectionId,
channelId,
brandId,
selectedCategory,
isSubCategory,
@@ -80,28 +119,64 @@ 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 ||
Number(filterState.selectedFilterCategory) === 531) &&
windowWidth >= 768;
useEffect(() => {
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: {} });
@@ -109,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;
@@ -144,7 +260,7 @@ const CategoryPage = () => {
if (filterState.selectedFilterCategory) {
const cat = findCategoryById(
categoriesData?.data,
filterState.selectedFilterCategory
filterState.selectedFilterCategory,
);
return cat?.name || "Category";
}
@@ -169,7 +285,12 @@ const CategoryPage = () => {
selectedFilterBrand: null,
}));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setPageState((prev) => ({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: prev.sorting,
}));
setAllProducts([]);
setHasMore(true);
@@ -177,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 });
};
@@ -195,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() },
@@ -256,50 +374,78 @@ 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 className={styles.sum}>
<strong>{t("category.total")}:</strong> <br />
{totalItems} {t("category.items")}
<button
className={styles.filterButton}
onClick={() => setIsFilterDrawerOpen(true)}
>
{t("category.filter")} <LuFilter />
</button>
<BrandSidebar
brands={filtersData?.brands || []}
selectedBrand={filterState.selectedFilterBrand}
onBrandSelect={handleFilterBrandSelect}
onBrandDeselect={handleFilterBrandDeselect}
/>
{/* <FilterSidebar onPriceSortChange={() => {}} currentPriceSort="none" /> */}
</div>
{selectedCategory?.children && !searchQuery && (
<div className={styles.subCategories}>
{selectedCategory.children.map((sub) => (
<button key={sub.id} onClick={() => handleCategoryClick(sub.id)}>
{sub.name}
</button>
))}
</div>
)}
<div className={styles.Container}>
<Drawer
title={t("category.filter")}
placement="right"
onClose={() => setIsFilterDrawerOpen(false)}
open={isFilterDrawerOpen}
width="80%"
>
<CategoryFilters
filtersData={filtersData}
selectedFilterCategory={filterState.selectedFilterCategory}
selectedFilterBrand={filterState.selectedFilterBrand}
brandSearchQuery={filterState.brandSearchQuery}
searchQuery={searchQuery}
minPrice={pageState.minPrice}
maxPrice={pageState.maxPrice}
onMinPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({
...prev,
minPrice: value,
currentPage: 1,
}));
}}
onMaxPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({
...prev,
maxPrice: value,
currentPage: 1,
}));
}}
onCategorySelect={handleFilterCategorySelect}
onCategoryDeselect={handleFilterCategoryDeselect}
onBrandSelect={handleFilterBrandSelect}
@@ -307,9 +453,69 @@ 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>
<div className={styles.Container}>
<CategoryFilters
className={styles.sidebar}
filtersData={filtersData}
selectedFilterCategory={filterState.selectedFilterCategory}
selectedFilterBrand={filterState.selectedFilterBrand}
brandSearchQuery={filterState.brandSearchQuery}
searchQuery={searchQuery}
minPrice={pageState.minPrice}
maxPrice={pageState.maxPrice}
onMinPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({
...prev,
minPrice: value,
currentPage: 1,
}));
}}
onMaxPriceChange={(value) => {
setAllProducts([]);
setHasMore(true);
setPageState((prev) => ({
...prev,
maxPrice: value,
currentPage: 1,
}));
}}
onCategorySelect={handleFilterCategorySelect}
onCategoryDeselect={handleFilterCategoryDeselect}
onBrandSelect={handleFilterBrandSelect}
onBrandDeselect={handleFilterBrandDeselect}
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 />
@@ -320,21 +526,34 @@ 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 : ""
}`}
>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
showFavoriteButton
showAddToCart
/>
))}
{filteredProducts.map((product) =>
isMobilePhoneView ? (
<MobilePhoneCard
key={product.id}
product={product}
showFavoriteButton
showAddToCart
/>
) : (
<ProductCard
key={product.id}
product={product}
showFavoriteButton
showAddToCart
/>
),
)}
</InfiniteScroll>
) : (
<div>{t("search.noResults")}</div>

View File

@@ -11,12 +11,12 @@ const DeliveryTerms = () => {
<p>Eltip bermek hyzmaty Aşgabat şäheriniň çägi bilen bir hatarda Büzmeýine we Änew şäherine hem elýeterlidir;</p>
</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>

View File

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

View File

@@ -1,63 +1,63 @@
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import styles from "./OrderDetail.module.scss";
import { 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>
);

View File

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

View File

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

View File

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

View File

@@ -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";
@@ -12,21 +12,24 @@ import {
import ReviewSection from "../../components/Review/index";
import { Modal } from "antd";
import { debounce } from "lodash";
import {
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "../../app/api/favoritesApi";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useCart } from "../../app/api/useCart";
import {
useAddToCartMutation,
useUpdateCartItemMutation,
useRemoveFromCartMutation,
useGetCartQuery,
} from "../../app/api/cartApi";
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,
@@ -39,62 +42,128 @@ 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 [quantity, setQuantity] = useState(0);
const product = productResponse?.data;
const similarProducts = similarProductsResponse?.data;
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
const [addFavorite] = useAddFavoriteMutation();
const [removeFavorite] = useRemoveFavoriteMutation();
const { data: favoriteProducts = [], refetch } = useGetFavoritesQuery();
const { data: favoriteProducts = [] } = useGetFavoritesQuery();
const [isLoading, setIsLoading] = useState(false);
const [localIsFavorite, setLocalIsFavorite] = useState(
favoriteProducts.some((fav) => fav.product?.id === product?.id),
);
const { data: cartData } = useGetCartQuery(undefined, {
selectFromResult: (result) => ({
data: result.data,
}),
});
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();
const [updateCartItem] = useUpdateCartItemMutation();
const [removeFromCart] = useRemoveFromCartMutation();
const [localQuantity, setLocalQuantity] = useState(0);
const getCartItem = () => {
if (!cartData?.data || typeof cartData.data !== "object") {
return null;
}
const allCartItems = Object.values(cartData.data).flat();
return allCartItems.find(
(item) =>
item.product?.id === product?.id || item.product_id === product?.id,
);
};
const cartItem = getCartItem();
const [pendingQuantity, setPendingQuantity] = useState(0);
useEffect(() => {
if (cartItem) {
setLocalQuantity(cartItem.quantity || cartItem.product_quantity || 0);
setPendingQuantity(cartItem.quantity || cartItem.product_quantity || 0);
} else {
setLocalQuantity(0);
setPendingQuantity(0);
}
}, [cartData, cartItem]);
const cartItem = getCartItem(product?.id || productId);
// ✅ Sync local state with server cart
useEffect(() => {
const qty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0,
10,
);
setLocalQuantity(qty);
setPendingQuantity(qty);
}, [cartItem]);
// ✅ Sync favorite status
useEffect(() => {
if (Array.isArray(favoriteProducts)) {
const isFav = favoriteProducts.some(
@@ -104,78 +173,55 @@ const ProductPage = ({
}
}, [favoriteProducts, product?.id]);
// ✅ Toggle Favorite
const handleToggleFavorite = async (event) => {
event.preventDefault();
event.stopPropagation();
if (isLoading) return;
setIsLoading(true);
const originalState = localIsFavorite;
setLocalIsFavorite(!originalState); // Optimistic Update
try {
if (localIsFavorite) {
const result = await removeFavorite(product.id).unwrap();
if (result === "Removed" || result?.status === "success") {
setLocalIsFavorite(false);
}
if (originalState) {
await removeFavorite(product.id).unwrap();
} else {
const result = await addFavorite(product.id).unwrap();
if (result === "Added" || result?.status === "success") {
setLocalIsFavorite(true);
}
await addFavorite(product.id).unwrap();
}
// Refetch after changing favorite status
await refetch();
} catch (error) {
console.error("Failed to toggle favorite:", error);
setLocalIsFavorite(originalState); // Rollback
} finally {
setIsLoading(false);
}
};
// ✅ Add to Cart (Initial)
const handleAddToCart = async (event) => {
event.preventDefault();
event.stopPropagation();
// Check if stock is available
if (product.stock <= 0) {
setStockErrorModalVisible(true);
return;
}
setLocalQuantity((prev) => prev + 1);
setPendingQuantity((prev) => prev + 1);
setLocalQuantity(1);
setPendingQuantity(1);
try {
await addToCart({ productId: product.id, quantity: 1 }).unwrap();
} catch (error) {
console.error("Failed to add to cart:", error);
setLocalQuantity((prev) => prev - 1);
setPendingQuantity((prev) => prev - 1);
setLocalQuantity(0);
setPendingQuantity(0);
}
};
useEffect(() => {
const updateCart = debounce(async () => {
if (pendingQuantity !== localQuantity) {
try {
await updateCartItem({
productId: product.id,
quantity: pendingQuantity,
}).unwrap();
} catch (error) {
console.error("Failed to update cart item:", error);
}
}
}, 500);
updateCart();
return () => updateCart.cancel();
}, [pendingQuantity]);
const handleQuantityIncrease = (event) => {
event.preventDefault();
event.stopPropagation();
if (isLoading) return;
if (localQuantity >= product.stock) {
@@ -187,11 +233,9 @@ const ProductPage = ({
setPendingQuantity((prev) => prev + 1);
};
// Update quantity decrease handler
const handleQuantityDecrease = (event) => {
event.preventDefault();
event.stopPropagation();
if (isLoading) return;
if (pendingQuantity <= 1) {
@@ -200,6 +244,9 @@ const ProductPage = ({
setIsLoading(true);
removeFromCart({ productId: product.id })
.unwrap()
.then(() => {
// Success handled by hook
})
.catch(() => {
setLocalQuantity(1);
setPendingQuantity(1);
@@ -213,33 +260,35 @@ const ProductPage = ({
}
};
// ✅ Debounced Cart Update
useEffect(() => {
const updateCart = async () => {
if (pendingQuantity !== quantity && pendingQuantity > 0) {
try {
setIsLoading(true);
await updateCartItem({
productId: product.id,
quantity: pendingQuantity,
}).unwrap();
} catch (error) {
console.error("Failed to update cart item:", error);
setLocalQuantity(quantity);
setPendingQuantity(quantity);
} finally {
setIsLoading(false);
}
}
};
const serverQty = parseInt(
cartItem?.quantity || cartItem?.product_quantity || 0,
10,
);
const debouncedUpdate = debounce(updateCart, 300);
if (pendingQuantity !== quantity) {
debouncedUpdate();
if (pendingQuantity === serverQty || pendingQuantity <= 0) {
return;
}
return () => debouncedUpdate.cancel();
}, [pendingQuantity, quantity, product, updateCartItem]);
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]);
if (productLoading || similarProductsLoading) return <Loader />;
if (productError || similarProductsError)
@@ -258,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 */}
@@ -275,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}
@@ -285,172 +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 && (
<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>
</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}
@@ -476,6 +581,7 @@ const ProductPage = ({
</div>
</div>
{/* Stock modal */}
<Modal
title={t("common.warning")}
open={stockErrorModalVisible}
@@ -504,3 +610,4 @@ const ProductPage = ({
};
export default ProductPage;

View File

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

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

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

View File

@@ -2,6 +2,9 @@ import React, { useState, useEffect } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import 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;

View File

@@ -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 /> },
],
},
]);

View File

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