Compare commits

..

29 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
84 changed files with 8352 additions and 2397 deletions

10
ecosystem.config.js Normal file
View File

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

28
fetch.js Normal file
View File

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

615
package-lock.json generated
View File

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

View File

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

1
public/data.json Normal file

File diff suppressed because one or more lines are too long

41
server.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,14 @@ export const categoriesApi = baseApi.injectEndpoints({
}), }),
getCategoryProducts: builder.query({ getCategoryProducts: builder.query({
query: ({ categoryId, page = 1, limit, brands, min_price, max_price }) => { query: ({ categoryId, page = 1, perPage = 12, brands, min_price, max_price, sorting }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', page); params.append("page", page);
if (limit) params.append('limit', limit); params.append("perPage", perPage);
if (brands) params.append('brands', brands); if (brands) params.append("brands", brands);
if (min_price) params.append('min_price', min_price); if (min_price) params.append("min_price", min_price);
if (max_price) params.append('max_price', max_price); if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting);
return `categories/${categoryId}/products?${params.toString()}`; return `categories/${categoryId}/products?${params.toString()}`;
}, },
transformResponse: (response) => ({ transformResponse: (response) => ({
@@ -24,77 +24,103 @@ export const categoriesApi = baseApi.injectEndpoints({
}), }),
getAllCategoryProducts: builder.query({ getAllCategoryProducts: builder.query({
async queryFn(category, queryApi, extraOptions, baseQuery) { async queryFn(category, _queryApi, _extraOptions, baseQuery) {
const fetchProducts = async (categoryId) => { const fetchProducts = async (categoryId) => {
const result = await baseQuery(`categories/${categoryId}/products`); const result = await baseQuery(`categories/${categoryId}/products`);
return result.data ? result.data.data : []; return result.data ? result.data.data : [];
}; };
let allProducts = await fetchProducts(category.id); let allProducts = await fetchProducts(category.id);
for (const child of category.children) { for (const child of category.children) {
const childProducts = await fetchProducts(child.id); const childProducts = await fetchProducts(child.id);
allProducts = [...allProducts, ...childProducts]; allProducts = [...allProducts, ...childProducts];
} }
return { data: allProducts }; return { data: allProducts };
}, },
}), }),
getAllCategoryProductsPaginated: builder.query({ getAllCategoryProductsPaginated: builder.query({
async queryFn( async queryFn(
{ category, page = 1, limit = 6, brands, min_price, max_price }, { category, page = 1, perPage = 12, brands, min_price, max_price, sorting },
queryApi, _queryApi,
extraOptions, _extraOptions,
baseQuery baseQuery
) { ) {
if (!category) return { data: [] }; if (!category) return { data: { data: [], pagination: { currentPage: 1, hasMorePages: false } } };
try { try {
const hasMoreByCategory = {};
const fetchProductsForPage = async (categoryIds, currentPage) => {
let allPageProducts = [];
const perCategoryLimit = Math.ceil(limit / categoryIds.length);
for (const categoryId of categoryIds) {
const params = new URLSearchParams();
params.append('page', currentPage);
params.append('limit', perCategoryLimit);
if (brands) params.append('brands', brands);
if (min_price) params.append('min_price', min_price);
if (max_price) params.append('max_price', max_price);
const result = await baseQuery(
`categories/${categoryId}/products?${params.toString()}`
);
if (result.data && result.data.data) {
allPageProducts = [...allPageProducts, ...result.data.data];
hasMoreByCategory[categoryId] = !!result.data.pagination.next_page_url;
}
}
return allPageProducts;
};
const categoryIds = [category.id]; const categoryIds = [category.id];
if (category.children && category.children.length > 0) { if (category.children?.length > 0) {
category.children.forEach((child) => categoryIds.push(child.id)); category.children.forEach((child) => categoryIds.push(child.id));
} }
const productsForPage = await fetchProductsForPage(categoryIds, page); // Tek category — direkt fetch, limit tam uygulanır
if (categoryIds.length === 1) {
const params = new URLSearchParams();
params.append("page", page);
params.append("perPage", perPage);
if (brands) params.append("brands", brands);
if (min_price) params.append("min_price", min_price);
if (max_price) params.append("max_price", max_price);
if (sorting) params.append("sorting", sorting);
const hasMorePages = Object.values(hasMoreByCategory).some( const result = await baseQuery(
(hasMore) => hasMore `categories/${categoryIds[0]}/products?${params.toString()}`
); );
if (result.error) return { error: result.error };
return { return {
data: { data: {
data: productsForPage, data: result.data?.data || [],
pagination: { pagination: {
currentPage: page, currentPage: page,
hasMorePages: hasMorePages, 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: allProducts,
pagination: {
currentPage: page,
hasMorePages,
}, },
}, },
}; };

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { Home, ShoppingBag, ShoppingCart, Heart, User } from "lucide-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import styles from "./FooterBar.module.scss"; 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 { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const FooterBar = () => { const FooterBar = () => {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { data: cartData } = useGetCartQuery(); const { cartCount } = useCart();
const { data: favoriteData } = useGetFavoritesQuery(); 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 favoriteCount = favoriteData?.length || 0;
const navItems = [ const navItems = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { CartIcon, WishlistIcon, BrandIcon, OrderIcon } from "../Icons"; import { CartIcon, WishlistIcon, BrandIcon, OrderIcon, StoreIcon } from "../Icons";
import styles from "./Navbar.module.scss"; import styles from "./Navbar.module.scss";
import { UserOutlined, LogoutOutlined, HomeOutlined } from "@ant-design/icons"; import { UserOutlined, LogoutOutlined, HomeOutlined, ShopOutlined } from "@ant-design/icons";
import { FaGlobe } from "react-icons/fa6"; import { FaGlobe } from "react-icons/fa6";
import { Input, Badge, Menu, Dropdown } from "antd"; import { Input, Badge, Menu, Dropdown } from "antd";
const { Search } = Input; const { Search } = Input;
@@ -17,7 +17,7 @@ import { CiLocationOn } from "react-icons/ci";
import Sidebar from "../CategorySideBar"; import Sidebar from "../CategorySideBar";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchProductQuery } from "../../app/api/searchApi"; 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 { useGetOrdersQuery } from "../../app/api/orderApi";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi"; import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useAuth } from "../../context/authContext"; import { useAuth } from "../../context/authContext";
@@ -31,27 +31,11 @@ const NavbarDown = () => {
const { data: searchData, refetch } = useSearchProductQuery(searchQuery, { const { data: searchData, refetch } = useSearchProductQuery(searchQuery, {
skip: !searchQuery, skip: !searchQuery,
}); });
const { data: cartData } = useGetCartQuery(undefined, {
refetchOnMountOrArgChange: false, const { cartCount: cartItemCount } = useCart();
});
const { isAuthenticated, logout } = useAuth(); 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 { data: ordersData } = useGetOrdersQuery();
const ordersItemCount = ordersData?.length || 0; const ordersItemCount = ordersData?.length || 0;
@@ -151,7 +135,7 @@ const NavbarDown = () => {
]; ];
return ( return (
<header className={styles.navbar}> <header className={styles.navbar} style={{ width: "100%" }}>
<div className={styles.navbarDown} style={{ position: "sticky" }}> <div className={styles.navbarDown} style={{ position: "sticky" }}>
<nav className={styles.navLinks}> <nav className={styles.navLinks}>
<ul> <ul>
@@ -167,6 +151,15 @@ const NavbarDown = () => {
</button> </button>
</Link> </Link>
</li> </li>
<div className={styles.stick}></div>
<li>
<Link to={"/stores"}>
<button className={styles.navButton}>
<ShopOutlined />
{t("navbar.stores")}
</button>
</Link>
</li>
<li className={styles.searchWrapper}> <li className={styles.searchWrapper}>
<CiSearch /> <CiSearch />
<input <input
@@ -271,7 +264,10 @@ const NavbarDown = () => {
</div> </div>
<div className={styles.stick}></div> <div className={styles.stick}></div>
<div className={styles.location}> <div className={styles.location}>
<CiLocationOn /> Aşgabat <Link to={'/stores'} style={{textDecoration: 'none'}}><button className={styles.navButton}>
<ShopOutlined />
{t("navbar.stores")}
</button></Link>
</div> </div>
<div className={styles.stick}></div> <div className={styles.stick}></div>
<div className={styles.searchIcon} onClick={toggleSearch}> <div className={styles.searchIcon} onClick={toggleSearch}>

View File

@@ -4,40 +4,101 @@ import SignupForm from "../BeSeller/index";
import React, { useState } from "react"; import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LogoWithText } from "../Icons"; import { LogoWithText } from "../Icons";
import Logo from "../../assets/logo2.png" import Logo from "../../assets/logo2.png";
import { useTranslation } from "react-i18next";
import tm from "../../assets/tm.png";
import ru from "../../assets/ru.png";
import en from "../../assets/en.png";
import NavbarDown from "./NavbarDown";
const Navbar = () => { const Navbar = () => {
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const showModal = () => { const { i18n } = useTranslation();
setIsModalVisible(true);
const changeLanguage = (langCode) => {
i18n.changeLanguage(langCode);
localStorage.setItem("preferredLanguage", langCode);
window.location.reload();
}; };
const handleCancel = () => { const languages = [
setIsModalVisible(false); { code: "tk", flag: tm, label: "TM" },
}; { code: "ru", flag: ru, label: "RU" },
{ code: "en", flag: en, label: "EN" },
];
const showModal = () => setIsModalVisible(true);
const handleCancel = () => setIsModalVisible(false);
return ( return (
<> <>
<header className={styles.navbar}> <header className={styles.navbar}>
<div className={styles.navbarUp}> <div className={styles.navbarUp}>
<div <div
style={{ maxWidth: "1366px", display: "flex", margin: "0 auto", alignItems: "center"}} style={{
maxWidth: "1366px",
display: "flex",
margin: "0 auto",
alignItems: "center",
}}
> >
<div className={styles.logo}> <div className={styles.logo}>
<div <div
className={styles.logoContainer} className={styles.logoContainer}
onClick={() => navigate("/")} onClick={() => navigate("/")}
> >
{/* <LogoWithText /> */} <img src={Logo} alt="" />
<img style={{width: "200px"}} src={Logo} alt="" />
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", padding: "8px 14px 6px" }}> <div className={styles.langSelector}>
{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}> <button className={styles.btn} onClick={showModal}>
Satyjy bol Satyjy bol
</button> </button>
<button
className={`${styles.btn} ${styles.btn__satyjy}`}
onClick={() => {
window.location.href = "/panel";
}}
>
Satyjy
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<NavbarDown />
</header> </header>
<Modal <Modal
open={isModalVisible} open={isModalVisible}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
export default { export default {
navbar: { navbar: {
category: "Categories", category: "Categories",
login: "Login", login: "Login",
signUp: "Sign Up", signUp: "Sign Up",
brands: "Brands", brands: "Brands",
stores: "Stores",
search: "Search by product name...", search: "Search by product name...",
cart: "Cart", cart: "Cart",
home: "Home", home: "Home",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "FLASH SALE",
ends_in: "Ends in:",
day: "day",
hour: "hr",
minute: "min",
second: "sec",
}, },
cart: { cart: {
basket: "Basket", basket: "Basket",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Your cart is empty", emptyCartTitle: "Your cart is empty",
emptyCartMessage: "Looks like you haven't added any items to your cart yet", emptyCartMessage: "Looks like you haven't added any items to your cart yet",
continueShopping: "Continue Shopping", continueShopping: "Continue Shopping",
pendingPriceTitle: "Price pending",
pendingPriceDesc: "The price of one or more items in this order has not yet been determined. Our operator will contact you to provide additional information.",
pendingPriceTooltipDesc: "The price of this item in the order has not been determined. The operator will call you and provide additional information."
}, },
checkout: { checkout: {
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
@@ -129,6 +142,8 @@ export default {
verify: "Verify", verify: "Verify",
name: "Name", name: "Name",
address: "Address", address: "Address",
seller_panel: "Seller Panel",
}, },
order: { order: {
orderDate: "Order Date", orderDate: "Order Date",
@@ -140,6 +155,7 @@ export default {
photo: "Photo", photo: "Photo",
brand: "Brand", brand: "Brand",
code: "Code", code: "Code",
channel: "Store",
quantity: "Quantity", quantity: "Quantity",
price: "Price", price: "Price",
total: "Total", total: "Total",
@@ -156,6 +172,7 @@ export default {
category: { category: {
total: "Total", total: "Total",
items: "items", items: "items",
filter: "Filters",
subCategories: "SubCategories", subCategories: "SubCategories",
order: "Order", order: "Order",
notSelected: "Not Selected", notSelected: "Not Selected",
@@ -167,11 +184,28 @@ export default {
neverMind: "Default", neverMind: "Default",
From_expensive_to_cheap: "From expensive to cheap", From_expensive_to_cheap: "From expensive to cheap",
From_cheap_to_expensive: "From cheap to expensive", 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: { product: {
productCode: "Product code", productCode: "Product code",
barCode: "Barcode", barCode: "Barcode",
similarProducts: "Similar Products", similarProducts: "Similar Products",
description: "Product description",
price: "Price",
readMore: "Read more...",
readLess: "Show less",
}, },
wishtList: { wishtList: {
likedProducts: "Favorites", likedProducts: "Favorites",
@@ -188,10 +222,13 @@ export default {
TermsofUseandPrivacyPolicy: "Terms of Use and Privacy Policy", TermsofUseandPrivacyPolicy: "Terms of Use and Privacy Policy",
mobile_applications: "Mobile applications", mobile_applications: "Mobile applications",
copyright: " All rights reserved.", 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_paragraph1:
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.", "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_paragraph3: "You can pay for the order as you like: cash or bank card upon receipt.", about_paragraph2:
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!" "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 { export default {
navbar: { navbar: {
category: "Категории", category: "Категории",
login: "Войти", login: "Войти",
signUp: "Регистрация", signUp: "Регистрация",
brands: "Бренды", brands: "Бренды",
stores: "Магазины",
search: "Поиск по названию товара...", search: "Поиск по названию товара...",
cart: "Корзина", cart: "Корзина",
home: "Главная", home: "Главная",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "ФЛЭШ-РАСПРОДАЖА",
ends_in: "До конца:",
day: "дн.",
hour: "ч.",
minute: "мин.",
second: "сек.",
}, },
cart: { cart: {
basket: "Корзина", basket: "Корзина",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Ваша корзина пуста", emptyCartTitle: "Ваша корзина пуста",
emptyCartMessage: "Похоже, вы еще не добавили ни одного товара в корзину", emptyCartMessage: "Похоже, вы еще не добавили ни одного товара в корзину",
continueShopping: "Продолжить покупки", continueShopping: "Продолжить покупки",
pendingPriceTitle: "Цена уточняется",
pendingPriceDesc: "Цена на один или несколько товаров в этом заказе еще не определена. Наш оператор свяжется с вами для предоставления дополнительной информации.",
pendingPriceTooltipDesc: "Цена на этот товар в заказе не определена. Оператор позвонит вам и предоставит дополнительную информацию."
}, },
checkout: { checkout: {
paymentMethod: "Способ оплаты", paymentMethod: "Способ оплаты",
@@ -125,7 +138,8 @@ export default {
verify: "Верификация", verify: "Верификация",
name: "Имя", name: "Имя",
address: "Address", address: "Address",
lastname:"Фамилия" lastname: "Фамилия",
seller_panel: "Панель продавца",
}, },
order: { order: {
orderDate: "Дата заказа", orderDate: "Дата заказа",
@@ -137,6 +151,7 @@ export default {
photo: "Фото", photo: "Фото",
brand: "Бренд", brand: "Бренд",
code: "Код", code: "Код",
channel: "Магазин",
quantity: "Количество", quantity: "Количество",
price: "Цена", price: "Цена",
total: "Итого", total: "Итого",
@@ -153,6 +168,7 @@ export default {
category: { category: {
total: "Всего", total: "Всего",
items: "товаров", items: "товаров",
filter: "Фильтры",
subCategories: "Подкатегории", subCategories: "Подкатегории",
order: "Сортировка", order: "Сортировка",
notSelected: "Не выбрано", notSelected: "Не выбрано",
@@ -164,11 +180,28 @@ export default {
neverMind: "По умолчанию", neverMind: "По умолчанию",
From_expensive_to_cheap: "От дорогих к дешевым", From_expensive_to_cheap: "От дорогих к дешевым",
From_cheap_to_expensive: "От дешевых к дорогим", 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: { product: {
productCode: "Код товара", productCode: "Код товара",
barCode: "Штрих-код", barCode: "Штрих-код",
similarProducts: "Похожие товары", similarProducts: "Похожие товары",
description: "Описание товара",
price: "Цена",
readMore: "Читать далее...",
readLess: "Свернуть",
}, },
wishtList: { wishtList: {
likedProducts: "Избранные", likedProducts: "Избранные",
@@ -187,9 +220,13 @@ export default {
"Условия использования и политика конфиденциальности", "Условия использования и политика конфиденциальности",
mobile_applications: "Мобильные приложения", mobile_applications: "Мобильные приложения",
copyright: "Все права защищены.", copyright: "Все права защищены.",
about_paragraph1: "Наш маркетплейс — это удобная онлайн-площадка, где вы найдёте всё в одном месте: от автозапчастей и электроники до товаров для дома и свежих продуктов. Мы работаем с 2019 года и за это время собрали сотни надёжных брендов, чтобы вы могли выбирать только лучшее. Ассортимент постоянно растёт — мы внимательно следим за вашими запросами и всегда стараемся предложить больше.", about_paragraph1:
about_paragraph2: "Наша миссия — сделать покупки простыми и удобными. Всё, что вам нужно, теперь можно заказать в пару кликов, не выходя из дома. Вы экономите время, силы и деньги — а мы заботимся о том, чтобы всё приехало быстро и без лишних хлопот.", "Наш маркетплейс — это удобная онлайн-площадка, где вы найдёте всё в одном месте: от автозапчастей и электроники до товаров для дома и свежих продуктов. Мы работаем с 2019 года и за это время собрали сотни надёжных брендов, чтобы вы могли выбирать только лучшее. Ассортимент постоянно растёт — мы внимательно следим за вашими запросами и всегда стараемся предложить больше.",
about_paragraph3: "Оплатить заказ можно как вам удобно: наличными или банковской картой при получении.", about_paragraph2:
about_paragraph4: "Мы всегда открыты к сотрудничеству и рады обратной связи. Есть идея, вопрос или предложение? Напишите нам — мы с удовольствием ответим!" "Наша миссия — сделать покупки простыми и удобными. Всё, что вам нужно, теперь можно заказать в пару кликов, не выходя из дома. Вы экономите время, силы и деньги — а мы заботимся о том, чтобы всё приехало быстро и без лишних хлопот.",
about_paragraph3:
"Оплатить заказ можно как вам удобно: наличными или банковской картой при получении.",
about_paragraph4:
"Мы всегда открыты к сотрудничеству и рады обратной связи. Есть идея, вопрос или предложение? Напишите нам — мы с удовольствием ответим!",
}, },
}; };

View File

@@ -1,9 +1,11 @@
export default { export default {
navbar: { navbar: {
category: "Kategoriýalar", category: "Kategoriýalar",
login: "Giriş", login: "Giriş",
signUp: "Agza bolmak", signUp: "Agza bolmak",
brands: "Brendler", brands: "Brendler",
stores: "Dükanlar",
search: "Haryt ady boýunça gözleg...", search: "Haryt ady boýunça gözleg...",
cart: "Sebet", cart: "Sebet",
home: "Baş sahypa", home: "Baş sahypa",
@@ -12,6 +14,14 @@ export default {
ru: "Русский", ru: "Русский",
en: "English", en: "English",
}, },
},
flashSales: {
flash_sale: "GYSGA WAGTLYK ARZANLADYŞ",
ends_in: "Gutarýança:",
day: "gün",
hour: "sag",
minute: "min",
second: "sek",
}, },
cart: { cart: {
basket: "Sebet", basket: "Sebet",
@@ -29,6 +39,9 @@ export default {
emptyCartTitle: "Sebediňiz boş", emptyCartTitle: "Sebediňiz boş",
emptyCartMessage: "Sebediňize entek hiç zat goşmadyňyz.", emptyCartMessage: "Sebediňize entek hiç zat goşmadyňyz.",
continueShopping: "Söwda etmegi dowam etdiriň", continueShopping: "Söwda etmegi dowam etdiriň",
pendingPriceTitle: "Bahasyny anyklamaly",
pendingPriceDesc: "Bu sargytdaky bir ýa-da birnäçe harydyň bahasy entek kesgitlenmedik. Operatorymyz siziň bilen habarlaşyp, goşmaça maglumat berer.",
pendingPriceTooltipDesc: "Bu sargytdaky harydyň bahasy kesgitlenmedik. Operator size jaň edip goşmaça maglumat berer."
}, },
checkout: { checkout: {
paymentMethod: "Töleg görnüşi", paymentMethod: "Töleg görnüşi",
@@ -128,7 +141,8 @@ export default {
verify: "Tassykla", verify: "Tassykla",
name: "Ady", name: "Ady",
address: "Salgy", address: "Salgy",
lastname:"Familýaňyz" lastname: "Familýaňyz",
seller_panel: "Satyjy paneli",
}, },
order: { order: {
orderDate: "Sargyt senesi", orderDate: "Sargyt senesi",
@@ -141,6 +155,7 @@ export default {
brand: "Brend", brand: "Brend",
code: "Kody", code: "Kody",
quantity: "Sany", quantity: "Sany",
channel: "Magazin",
price: "Bahasy", price: "Bahasy",
total: "Jemi", total: "Jemi",
orderNumber: "Sargyt belgisi", orderNumber: "Sargyt belgisi",
@@ -156,6 +171,7 @@ export default {
category: { category: {
total: "Jemi", total: "Jemi",
items: "haryt", items: "haryt",
filter: "Süzgüç",
subCategories: "Içki kategoriýalar", subCategories: "Içki kategoriýalar",
order: "Tertip", order: "Tertip",
notSelected: "Saýlanmadyk", notSelected: "Saýlanmadyk",
@@ -167,11 +183,28 @@ export default {
neverMind: "Sortlanmadyk", neverMind: "Sortlanmadyk",
From_expensive_to_cheap: "Gymmatdan arzana", From_expensive_to_cheap: "Gymmatdan arzana",
From_cheap_to_expensive: "Arzandan gymmada", From_cheap_to_expensive: "Arzandan gymmada",
price: "Bahasy",
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: { product: {
productCode: "Haryt kody", productCode: "Haryt kody",
barCode: "Çyzgyç kod", barCode: "Çyzgyç kod",
similarProducts: "Meňzeş harytlar", similarProducts: "Meňzeş harytlar",
description: "Haryt barada düşündiriş",
price: "Bahasy",
readMore: "Giňişleýin oka...",
readLess: "Gysgaltmak",
}, },
wishtList: { wishtList: {
likedProducts: "Halanlarym", likedProducts: "Halanlarym",
@@ -189,10 +222,13 @@ export default {
TermsofUseandPrivacyPolicy: "Ulanyş düzgünleri we gizlinlik syýasaty", TermsofUseandPrivacyPolicy: "Ulanyş düzgünleri we gizlinlik syýasaty",
mobile_applications: "Mobile goşundylar", mobile_applications: "Mobile goşundylar",
copyright: "Ähli hukuklar goralan.", 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_paragraph1:
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.", "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_paragraph3: "Sargydyňyzy özüňize amatly görnüşde töläp bilersiňiz: nagt ýa-da alandan soň kredit kartoçkasy bilen.", about_paragraph2:
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!" "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 { .cartHeader {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 12px;
justify-content: space-between; justify-content: space-between;
background-color: #f3f4f6; background-color: #f3f4f6;
padding-bottom: 15px; padding-top: 10px;
h2 { h2 {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
@@ -27,11 +26,11 @@
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
}
}
}
}
}
}
}
}
.cartProducts { .cartProducts {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -152,6 +151,7 @@
@media screen and (max-width: 720px) { @media screen and (max-width: 720px) {
flex-direction: row-reverse; flex-direction: row-reverse;
justify-content: space-between; justify-content: space-between;
gap:10px;
} }
.price { .price {
@@ -226,7 +226,7 @@
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
width: 100%; width: 100%;
position: static; position: static;
margin-top: 16px; margin-bottom: 16px;
} }
h3 { h3 {
@@ -524,3 +524,106 @@
} }
} }
} }
.pendingPriceBadgeWrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.pendingPriceBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #faeeda;
border: 0.5px solid #ef9f27;
color: #854f0b;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.pendingPriceTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-background-primary, #ffffff);
border: 0.5px solid var(--color-border-secondary, #e2e2e2);
border-radius: var(--border-radius-md, 6px);
padding: 8px 12px;
width: 220px;
font-size: 13px;
color: var(--color-text-primary, #333333);
line-height: 1.5;
z-index: 100;
white-space: normal;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@media (max-width: 767px) {
display: none;
}
strong {
display: block;
margin-bottom: 4px;
color: var(--color-text-primary, #000000);
}
}
:global {
.pending-price-modal {
.ant-modal-content {
border-radius: 12px;
padding: 24px;
@media (max-width: 767px) {
padding: 20px;
}
}
.ant-modal-header {
margin-bottom: 12px;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
@media (max-width: 767px) {
font-size: 16px;
}
}
}
.ant-modal-body {
p {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 0;
@media (max-width: 767px) {
font-size: 13px;
}
}
}
.ant-modal-footer {
margin-top: 20px;
.ant-btn-primary {
background-color: #888888;
border-color: #888888;
border-radius: 6px;
height: 36px;
padding: 0 20px;
font-weight: 500;
&:hover {
background-color: #666666;
border-color: #666666;
}
}
}
}
}

View File

@@ -2,24 +2,22 @@ import React, { useState, useRef, useEffect, useMemo } from "react";
import styles from "./CartPage.module.scss"; import styles from "./CartPage.module.scss";
import { FaTrashAlt } from "react-icons/fa"; import { FaTrashAlt } from "react-icons/fa";
import Checkout from "../../components/Checkout"; import Checkout from "../../components/Checkout";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Modal } from "antd"; import { Modal } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmptyCartState from "./emptyCart"; import EmptyCartState from "./emptyCart";
import { import {
useGetCartQuery,
useAddToCartMutation,
useRemoveFromCartMutation, useRemoveFromCartMutation,
useUpdateCartItemMutation, useUpdateCartItemMutation,
useCleanCartMutation, useCleanCartMutation,
} from "../../app/api/cartApi"; } from "../../app/api/cartApi";
import { useCart } from "../../app/api/useCart";
import { DecreaseIcon, IncreaseIcon } from "../../components/Icons"; import { DecreaseIcon, IncreaseIcon } from "../../components/Icons";
import { debounce } from "lodash";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const TruncatedDescription = ({ description, maxLength = 100 }) => { const TruncatedDescription = ({ description, maxLength = 100 }) => {
const [isExpanded, setIsExpanded] = useState(false);
const stripHtml = (html) => { const stripHtml = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html"); const doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent || ""; return doc.body.textContent || "";
@@ -32,9 +30,7 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
<div className={styles.truncatedDescription}> <div className={styles.truncatedDescription}>
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: isExpanded __html: shouldTruncate
? description
: shouldTruncate
? description.substring(0, maxLength) + "..." ? description.substring(0, maxLength) + "..."
: description, : description,
}} }}
@@ -44,37 +40,16 @@ const TruncatedDescription = ({ description, maxLength = 100 }) => {
}; };
const CartPage = () => { const CartPage = () => {
const { const { cartData, cartItems, isLoading } = useCart();
data: response = {}, const { t } = useTranslation();
error,
isError,
isLoading,
} = useGetCartQuery(undefined, {
refetchOnMountOrArgChange: 30, // ✅ Sadece 30 saniye sonra mount'ta refetch
refetchOnFocus: false,
refetchOnReconnect: false,
});
// 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 [checkoutStores, setCheckoutStores] = useState({});
const [addToCart] = useAddToCartMutation();
const [removeFromCart] = useRemoveFromCartMutation(); const [removeFromCart] = useRemoveFromCartMutation();
const [updateCartItem] = useUpdateCartItemMutation(); const [updateCartItem] = useUpdateCartItemMutation();
const [cleanCart] = useCleanCartMutation(); const [cleanCart] = useCleanCartMutation();
const [isExpanded, setIsExpanded] = useState(false);
const expandedRef = useRef(null);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [emptyCartModalVisible, setEmptyCartModalVisible] = useState(false); const [emptyCartModalVisible, setEmptyCartModalVisible] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [localQuantities, setLocalQuantities] = useState({}); const [localQuantities, setLocalQuantities] = useState({});
const [pendingQuantities, setPendingQuantities] = useState({}); const [pendingQuantities, setPendingQuantities] = useState({});
const [loadingItems, setLoadingItems] = useState({}); const [loadingItems, setLoadingItems] = useState({});
@@ -87,202 +62,130 @@ const CartPage = () => {
width: 400, width: 400,
}; };
// Convert grouped data to stores array
const stores = useMemo(() => { const stores = useMemo(() => {
return Object.entries(cartData).map(([storeSlug, items]) => { return Object.entries(cartData)
if (!items || !items.length) return null; .map(([storeSlug, items]) => {
if (!items?.length) return null;
// Get store info from first item
const storeInfo = items[0]?.product?.channel?.[0]; const storeInfo = items[0]?.product?.channel?.[0];
return { return {
id: storeInfo?.id || storeSlug, id: storeInfo?.id || storeSlug,
name: storeInfo?.name || storeSlug, name: storeInfo?.name || storeSlug,
slug: storeSlug, slug: storeSlug,
shipping_price: storeInfo?.shipping_price, shipping_price: storeInfo?.shipping_price,
items: items items,
}; };
}).filter(Boolean); })
.filter(Boolean);
}, [cartData]); }, [cartData]);
// ✅ Initialize local quantities from cart items
useEffect(() => { useEffect(() => {
const newLocalQuantities = {}; const newLocal = {};
const newPendingQuantities = {}; const newPending = {};
cartItems.forEach((item) => { cartItems.forEach((item) => {
const productId = item.product.id; const id = item.product.id;
const quantity = parseInt(item.product_quantity, 10) || 0; const qty = parseInt(item.product_quantity, 10) || 0;
newLocalQuantities[productId] = quantity; newLocal[id] = qty;
newPendingQuantities[productId] = quantity; newPending[id] = qty;
}); });
setLocalQuantities(newLocal);
setLocalQuantities(newLocalQuantities); setPendingQuantities(newPending);
setPendingQuantities(newPendingQuantities);
}, [cartItems]); }, [cartItems]);
// ✅ Debounced update - tek bir useEffect
useEffect(() => { useEffect(() => {
const debouncedUpdates = {}; const timers = {};
const updateItem = async (productId) => { Object.keys(pendingQuantities).forEach((productId) => {
const serverItem = cartItems.find((item) => item.product.id === productId); const serverItem = cartItems.find(
const serverQuantity = serverItem ? parseInt(serverItem.product_quantity, 10) : 0; (item) => String(item.product.id) === String(productId),
const pendingQuantity = pendingQuantities[productId]; );
const serverQty = serverItem
? parseInt(serverItem.product_quantity, 10)
: 0;
const pendingQty = pendingQuantities[productId];
// ✅ Eğer değişiklik yoksa, güncelleme yapma if (
if (pendingQuantity === undefined || pendingQuantity === serverQuantity) { pendingQty === undefined ||
pendingQty === serverQty ||
pendingQty <= 0
)
return; return;
}
timers[productId] = setTimeout(async () => {
try { try {
setLoadingItems((prev) => ({ ...prev, [productId]: true })); setLoadingItems((prev) => ({ ...prev, [productId]: true }));
await updateCartItem({ productId, quantity: pendingQty }).unwrap();
if (pendingQuantity <= 0) { } catch {
await removeFromCart({ productId }).unwrap(); setLocalQuantities((prev) => ({ ...prev, [productId]: serverQty }));
} else { setPendingQuantities((prev) => ({ ...prev, [productId]: serverQty }));
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 { } finally {
setLoadingItems((prev) => ({ ...prev, [productId]: false })); setLoadingItems((prev) => ({ ...prev, [productId]: false }));
} }
}; }, 500);
// ✅ 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]();
}); });
return () => { return () => Object.values(timers).forEach(clearTimeout);
Object.values(debouncedUpdates).forEach((debouncedFn) => }, [pendingQuantities, cartItems, updateCartItem]);
debouncedFn.cancel()
);
};
}, [pendingQuantities, cartItems, updateCartItem, removeFromCart]);
const handleQuantityIncrease = (productId) => (event) => { const handleQuantityIncrease = (productId) => (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (loadingItems[productId]) return; if (loadingItems[productId]) return;
const item = cartItems.find((item) => item.product.id === productId); const item = cartItems.find((i) => i.product.id === productId);
if (!item) return; if (!item || localQuantities[productId] >= item.product.stock) return;
if (localQuantities[productId] >= item.product.stock) { const newQty = (localQuantities[productId] || 0) + 1;
return; setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
} setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
const newQuantity = (localQuantities[productId] || 0) + 1;
setLocalQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
}; };
const handleQuantityDecrease = (productId) => (event) => { const handleQuantityDecrease = (productId) => (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (loadingItems[productId]) return; if (loadingItems[productId]) return;
const currentQuantity = localQuantities[productId] || 0; const currentQty = localQuantities[productId] || 0;
if (currentQty <= 1) {
if (currentQuantity <= 1) {
showDeleteConfirm(productId); showDeleteConfirm(productId);
return; return;
} }
const newQuantity = currentQuantity - 1; const newQty = currentQty - 1;
setLocalQuantities((prev) => ({ setLocalQuantities((prev) => ({ ...prev, [productId]: newQty }));
...prev, setPendingQuantities((prev) => ({ ...prev, [productId]: newQty }));
[productId]: newQuantity,
}));
setPendingQuantities((prev) => ({
...prev,
[productId]: newQuantity,
}));
}; };
const calculateStoreTotal = (storeItems) => { const getStoreShippingPrice = (store) =>
return storeItems.reduce((sum, item) => { store.shipping_price != null ? parseFloat(store.shipping_price) : 20;
const itemPrice = parseFloat(item.product.price_amount) || 0;
const itemQuantity = parseInt(item.product_quantity, 10) || 0; // Store içinde fiyatsız ürün var mı?
return sum + itemPrice * itemQuantity; const storeHasZeroPriceItem = (storeItems) =>
storeItems.some((item) => isPriceZero(item.product.price_amount));
const calculateStoreTotal = (storeItems) =>
storeItems.reduce((sum, item) => {
return (
sum +
(parseFloat(item.product.price_amount) || 0) *
(parseInt(item.product_quantity, 10) || 0)
);
}, 0); }, 0);
};
const handleCheckout = (storeId) =>
setCheckoutStores((prev) => ({ ...prev, [storeId]: true }));
const handleBackToCart = (storeId) =>
setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
const getStoreShippingPrice = (store) => { const handleOrderSubmit = async (storeId) => {
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) => {
if (checkoutStores[storeId] && checkoutRefs.current[storeId]) { if (checkoutStores[storeId] && checkoutRefs.current[storeId]) {
const success = await checkoutRefs.current[storeId](); const success = await checkoutRefs.current[storeId]();
if (success) { if (success) setCheckoutStores((prev) => ({ ...prev, [storeId]: false }));
setCheckoutStores(prev => ({ ...prev, [storeId]: false }));
}
} else { } else {
handleCheckout(storeId); handleCheckout(storeId);
} }
}; };
useEffect(() => {
const handleClickOutside = (event) => {
if (expandedRef.current && !expandedRef.current.contains(event.target)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showDeleteConfirm = (productId) => { const showDeleteConfirm = (productId) => {
setItemToDelete(productId); setItemToDelete(productId);
setDeleteModalVisible(true); setDeleteModalVisible(true);
@@ -292,50 +195,41 @@ const CartPage = () => {
if (itemToDelete) { if (itemToDelete) {
try { try {
await removeFromCart({ productId: itemToDelete }).unwrap(); await removeFromCart({ productId: itemToDelete }).unwrap();
setLocalQuantities((prev) => { setLocalQuantities((prev) => {
const newState = { ...prev }; const s = { ...prev };
delete newState[itemToDelete]; delete s[itemToDelete];
return newState; return s;
}); });
setPendingQuantities((prev) => { setPendingQuantities((prev) => {
const newState = { ...prev }; const s = { ...prev };
delete newState[itemToDelete]; delete s[itemToDelete];
return newState; return s;
}); });
} catch (error) { } catch (e) {
console.error("Failed to remove item:", error); console.error("Failed to remove item:", e);
} }
} }
setDeleteModalVisible(false); setDeleteModalVisible(false);
setItemToDelete(null); setItemToDelete(null);
}; };
const showEmptyCartConfirm = () => {
setEmptyCartModalVisible(true);
};
const handleEmptyCartConfirm = async () => { const handleEmptyCartConfirm = async () => {
try { try {
await cleanCart().unwrap(); await cleanCart().unwrap();
setLocalQuantities({}); setLocalQuantities({});
setPendingQuantities({}); setPendingQuantities({});
setCheckoutStores({}); setCheckoutStores({});
} catch (error) { } catch (e) {
console.error("Failed to clean cart:", error); console.error("Failed to clean cart:", e);
} }
setEmptyCartModalVisible(false); setEmptyCartModalVisible(false);
}; };
const getTotalItemCount = () => { const getTotalItemCount = () =>
return cartItems.reduce( cartItems.reduce(
(sum, item) => sum + parseInt(item.product_quantity, 10), (sum, item) => sum + parseInt(item.product_quantity, 10),
0 0,
); );
};
return ( return (
<div className={styles.cartContainer}> <div className={styles.cartContainer}>
@@ -375,21 +269,20 @@ const CartPage = () => {
<h2> <h2>
{t("cart.basket")} ({getTotalItemCount()}) {t("cart.basket")} ({getTotalItemCount()})
</h2> </h2>
<div>
<button <button
className={styles.deleteBtn} className={styles.deleteBtn}
style={{ padding: "4px 12px" }} style={{ padding: "4px 12px" }}
onClick={showEmptyCartConfirm} onClick={() => setEmptyCartModalVisible(true)}
> >
<FaTrashAlt /> {t("cart.clearCart")} <FaTrashAlt /> {t("cart.clearCart")}
</button> </button>
</div> </div>
</div>
{stores.map((store) => { {stores.map((store) => {
const shippingPrice = getStoreShippingPrice(store); const shippingPrice = getStoreShippingPrice(store);
const storeTotal = calculateStoreTotal(store.items); const storeTotal = calculateStoreTotal(store.items);
const totalWithShipping = storeTotal + shippingPrice; const totalWithShipping = storeTotal + shippingPrice;
const hasZeroPrice = storeHasZeroPriceItem(store.items);
return ( return (
<div key={store.id} className={styles.storeSection}> <div key={store.id} className={styles.storeSection}>
@@ -397,10 +290,10 @@ const CartPage = () => {
<Checkout <Checkout
cartItems={store.items} cartItems={store.items}
shippingPrice={shippingPrice} shippingPrice={shippingPrice}
productIds={store.items.map(item => item.product.id)} productIds={store.items.map((item) => item.product.id)}
onBackToCart={() => handleBackToCart(store.id)} onBackToCart={() => handleBackToCart(store.id)}
onPlaceOrder={(placeOrderFn) => { onPlaceOrder={(fn) => {
checkoutRefs.current[store.id] = placeOrderFn; checkoutRefs.current[store.id] = fn;
}} }}
/> />
) : ( ) : (
@@ -427,23 +320,31 @@ const CartPage = () => {
</div> </div>
<div className={styles.priceQuantity}> <div className={styles.priceQuantity}>
<span className={styles.price}> <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> </span>
<div className={styles.quantityControls}> <div className={styles.quantityControls}>
<button <button
onClick={handleQuantityDecrease(item.product.id)} onClick={handleQuantityDecrease(
item.product.id,
)}
className={styles.quantityBtn} className={styles.quantityBtn}
disabled={loadingItems[item.product.id]} disabled={loadingItems[item.product.id]}
> >
<DecreaseIcon /> <DecreaseIcon />
</button> </button>
<span> <span>
{localQuantities[item.product.id] !== undefined {localQuantities[item.product.id] !==
undefined
? localQuantities[item.product.id] ? localQuantities[item.product.id]
: parseInt(item.product_quantity, 10) || 0} : parseInt(item.product_quantity, 10) ||
0}
</span> </span>
<button <button
onClick={handleQuantityIncrease(item.product.id)} onClick={handleQuantityIncrease(
item.product.id,
)}
className={styles.quantityBtn} className={styles.quantityBtn}
disabled={loadingItems[item.product.id]} disabled={loadingItems[item.product.id]}
> >
@@ -454,7 +355,9 @@ const CartPage = () => {
<div className={styles.deleteBtnContainer}> <div className={styles.deleteBtnContainer}>
<button <button
className={styles.deleteBtn} className={styles.deleteBtn}
onClick={() => showDeleteConfirm(item.product.id)} onClick={() =>
showDeleteConfirm(item.product.id)
}
> >
<FaTrashAlt /> <FaTrashAlt />
</button> </button>
@@ -466,9 +369,21 @@ const CartPage = () => {
</div> </div>
)} )}
{/* ✅ Store Summary - fiyatsız ürün varsa "Baha anyklamak" */}
<div className={styles.storeSummary}> <div className={styles.storeSummary}>
<div className={styles.cartContent}> <div className={styles.cartContent}>
<h3>{store.name} - {t("cart.basket")}:</h3> <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}> <div className={styles.summaryRow}>
<span>{t("cart.price")}:</span> <span>{t("cart.price")}:</span>
<span>{storeTotal.toFixed(2)} m.</span> <span>{storeTotal.toFixed(2)} m.</span>
@@ -481,19 +396,22 @@ const CartPage = () => {
<span>{t("cart.total")}:</span> <span>{t("cart.total")}:</span>
<span>{totalWithShipping.toFixed(2)} m.</span> <span>{totalWithShipping.toFixed(2)} m.</span>
</div> </div>
</>
)}
</div> </div>
<button <button
onClick={() => handleOrderSubmit(store.id, store.items)} onClick={() => handleOrderSubmit(store.id)}
className={styles.checkoutBtn} className={styles.checkoutBtn}
> >
{checkoutStores[store.id] ? t("cart.order") : t("cart.prepareOrders")} {checkoutStores[store.id]
? t("cart.order")
: t("cart.prepareOrders")}
</button> </button>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
{/* Mobile sticky summary */} {/* Mobile sticky summary */}
{/* <div className={styles.container}> {/* <div className={styles.container}>
<div className={styles.summaryCard} ref={expandedRef}> <div className={styles.summaryCard} ref={expandedRef}>

View File

@@ -1,3 +1,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 { .categoryPage {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -35,42 +309,35 @@
} }
} }
.bars { .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) { @media screen and (min-width: 1024px) {
display: none; display: none;
} }
.sum {
color: #6b7280; .filterButton {
font-size: 12px; position: fixed;
text-align: left; bottom: 80px;
background-color: transparent; right: 20px;
border: 1px solid #6b7280; z-index: 1000;
padding: 3px 6px; background-color: #d32824;
display: block; border-radius: 12px;
border-radius: 0.5rem; font-size: 16px;
margin: 0; 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 { .subCategories {
display: flex; display: flex;
@@ -100,15 +367,18 @@
gap: 20px; gap: 20px;
margin-bottom: 15px; margin-bottom: 15px;
} }
aside {
.sidebar {
width: 250px; width: 250px;
position: sticky; position: sticky;
top: 5rem; top: 5rem;
background-color: #ffff; background-color: #ffff;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow-y: auto;
height: calc(-8.25rem + 100vh); height: calc(-8.25rem + 100vh);
@media screen and (max-width: 1280px) { @media screen and (max-width: 1280px) {
width: 200px; width: 200px;
} }
@@ -124,98 +394,7 @@
font-size: 24px; 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 { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -230,10 +409,18 @@
} }
} }
.productGrid::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
.productsContainer { .productsContainer {
flex: 1; flex: 1;
.productGrid { .productGrid {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
overflow: hidden !important;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
gap: 20px; gap: 20px;
@@ -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 { useTranslation } from "react-i18next";
import { TiTick } from "react-icons/ti"; import { TiTick } from "react-icons/ti";
import { Divider } from "antd";
import styles from "../CategoryPage.module.scss"; import styles from "../CategoryPage.module.scss";
const CategoryFilters = ({ const CategoryFilters = ({
@@ -9,18 +10,50 @@ const CategoryFilters = ({
selectedFilterBrand, selectedFilterBrand,
brandSearchQuery, brandSearchQuery,
searchQuery, searchQuery,
minPrice,
maxPrice,
onMinPriceChange,
onMaxPriceChange,
onCategorySelect, onCategorySelect,
onCategoryDeselect, onCategoryDeselect,
onBrandSelect, onBrandSelect,
onBrandDeselect, onBrandDeselect,
onBrandSearchChange, onBrandSearchChange,
sorting = "",
onSortingChange = () => {},
className,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const pricePresets = [
{ label: t("category.under50"), min: 0, max: 50 },
{ label: t("category.under100"), min: 0, max: 100 },
{ label: t("category.from50to200"), min: 50, max: 200 },
{ label: t("category.from200to500"), min: 200, max: 500 },
{ label: t("category.from500to1000"), min: 500, max: 1000 },
{ label: t("category.over1000"), min: 1000, max: 999999 },
];
const handlePricePreset = (preset) => {
// Eğer zaten aktifse, sıfırla
if (minPrice === preset.min.toString() && maxPrice === preset.max.toString()) {
onMinPriceChange("");
onMaxPriceChange("");
} else {
onMinPriceChange(preset.min.toString());
onMaxPriceChange(preset.max.toString());
}
};
const sortOptions = [
{ value: "price_amount-ascending", label: t("category.priceLowToHigh") },
{ value: "price_amount-descending", label: t("category.priceHighToLow") },
];
if (searchQuery) return null; if (searchQuery) return null;
return ( return (
<aside className={styles.sidebar}> <aside className={`${styles.filtersContainer} ${className}`}>
{filtersData?.categories?.length > 0 && ( {filtersData?.categories?.length > 0 && (
<div className={styles.filterSection}> <div className={styles.filterSection}>
<h3>{t("category.subCategories")}</h3> <h3>{t("category.subCategories")}</h3>
@@ -65,7 +98,7 @@ const CategoryFilters = ({
.filter((brand) => .filter((brand) =>
brand.name brand.name
.toLowerCase() .toLowerCase()
.includes(brandSearchQuery.toLowerCase()) .includes(brandSearchQuery.toLowerCase()),
) )
.map((brand) => ( .map((brand) => (
<li key={brand.id}> <li key={brand.id}>
@@ -91,6 +124,72 @@ const CategoryFilters = ({
</ul> </ul>
</div> </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> </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, useGetFiltersQuery,
useLazyGetFiltersQuery, useLazyGetFiltersQuery,
} from "../../../app/api/filtersApi"; } from "../../../app/api/filtersApi";
import { useGetChannelsQuery } from "../../../app/api/channelsApi";
const useCategoryData = ({ const useCategoryData = ({
categoryId, categoryId,
collectionId, collectionId,
brandId, brandId,
channelId,
selectedFilterCategory, selectedFilterCategory,
searchQuery, searchQuery,
}) => { }) => {
@@ -23,8 +25,9 @@ const useCategoryData = ({
if (categoryId) return { category_id: categoryId }; if (categoryId) return { category_id: categoryId };
if (collectionId) return { collection_id: collectionId }; if (collectionId) return { collection_id: collectionId };
if (brandId) return { brand_id: brandId }; if (brandId) return { brand_id: brandId };
if (channelId) return { channel_id: channelId };
return null; return null;
}, [categoryId, collectionId, brandId, selectedFilterCategory, searchQuery]); }, [categoryId, collectionId, brandId, channelId, selectedFilterCategory, searchQuery]);
const { const {
data: filtersData, data: filtersData,
@@ -44,6 +47,19 @@ const useCategoryData = ({
skip: !collectionId, skip: !collectionId,
}); });
const {
data: channelsListData,
isLoading: channelsLoading,
error: channelsError,
} = useGetChannelsQuery({ perPage: 100 }, {
skip: !channelId,
});
const channelData = useMemo(() => {
if (!channelId || !channelsListData?.data) return null;
return channelsListData.data.find(c => String(c.id) === String(channelId));
}, [channelId, channelsListData]);
const isSubCategory = useMemo(() => { const isSubCategory = useMemo(() => {
if (!categoriesData?.data || !categoryId) return false; if (!categoriesData?.data || !categoryId) return false;
@@ -92,8 +108,8 @@ const useCategoryData = ({
setSelectedCategory(category); setSelectedCategory(category);
}, [categoryId, categoriesData]); }, [categoryId, categoriesData]);
const isLoading = filtersLoading || collectionLoading; const isLoading = filtersLoading || collectionLoading || channelsLoading;
const hasError = filtersError || collectionError; const hasError = filtersError || collectionError || channelsError;
return { return {
categoriesData, categoriesData,
@@ -101,6 +117,7 @@ const useCategoryData = ({
isSubCategory, isSubCategory,
filtersData: activeFilters, filtersData: activeFilters,
collectionData, collectionData,
channelData,
isLoading, isLoading,
hasError, hasError,
fetchFilters, fetchFilters,

View File

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

View File

@@ -1,49 +1,85 @@
"use client";
import { useEffect, useState, useMemo, useRef } from "react"; import { useEffect, useState, useMemo, useRef } from "react";
import { useParams, useLocation, useNavigate } from "react-router-dom"; import { useParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Result, Button } from "antd"; import { Result, Button, Drawer } from "antd";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import { LuFilter } from "react-icons/lu";
import styles from "./CategoryPage.module.scss"; import styles from "./CategoryPage.module.scss";
import ProductCard from "../../components/ProductCard/index"; 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 Loader from "../../components/Loader/index";
import CategoryFilters from "./components/CategoryFilters"; import CategoryFilters from "./components/CategoryFilters";
import CategoryBreadcrumbs from "./components/CategoryBreadcrumbs"; import CategoryBreadcrumbs from "./components/CategoryBreadcrumbs";
import useCategoryData from "./hooks/useCategoryData"; import useCategoryData from "./hooks/useCategoryData";
import useCategoryProducts from "./hooks/useCategoryProducts"; import useCategoryProducts from "./hooks/useCategoryProducts";
import Carconfigurator from "../../components/CarConfigurator/Carconfigurator";
import MobilePhoneCard from "./components/Mobilephonecard";
const CategoryPage = () => { const CategoryPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { categoryId, collectionId, brandId } = useParams(); const { categoryId, collectionId, brandId, channelId } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [pageState, setPageState] = useState({ const routeKey = useMemo(
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}-${channelId || "x"}`,
[categoryId, collectionId, brandId, channelId],
);
const getSavedState = (key, defaultVal) => {
if (location.state?.clearFilters) {
return defaultVal;
}
try {
const saved = sessionStorage.getItem(`category_${key}_${routeKey}`);
if (saved) return JSON.parse(saved);
} catch (e) {
console.error(e);
}
return defaultVal;
};
const getSavedStateByKey = (route, key) => {
try {
const saved = sessionStorage.getItem(`category_${key}_${route}`);
if (saved) return JSON.parse(saved);
} catch (e) {
console.error(e);
}
return null;
};
const [pageState, setPageState] = useState(() => getSavedState("pageState", {
currentPage: 1, currentPage: 1,
minPrice: "", minPrice: "",
maxPrice: "", maxPrice: "",
}); sorting: "",
}));
const [filterState, setFilterState] = useState({ const [filterState, setFilterState] = useState(() => getSavedState("filterState", {
selectedFilterCategory: null, selectedFilterCategory: null,
selectedFilterBrand: null, selectedFilterBrand: null,
brandSearchQuery: "", brandSearchQuery: "",
}); }));
const routeKey = useMemo( const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
() => `${categoryId || "x"}-${collectionId || "x"}-${brandId || "x"}`, const [windowWidth, setWindowWidth] = useState(window.innerWidth);
[categoryId, collectionId, brandId]
); useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const prevRouteRef = useRef(routeKey); const prevRouteRef = useRef(routeKey);
const isInitialMount = useRef(true); 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 searchQuery = location.state?.searchQuery || null;
const { const {
@@ -52,6 +88,7 @@ const CategoryPage = () => {
isSubCategory, isSubCategory,
filtersData, filtersData,
collectionData, collectionData,
channelData,
isLoading: dataLoading, isLoading: dataLoading,
hasError: dataError, hasError: dataError,
fetchFilters, fetchFilters,
@@ -59,6 +96,7 @@ const CategoryPage = () => {
categoryId, categoryId,
collectionId, collectionId,
brandId, brandId,
channelId,
selectedFilterCategory: filterState.selectedFilterCategory, selectedFilterCategory: filterState.selectedFilterCategory,
searchQuery, searchQuery,
}); });
@@ -72,6 +110,7 @@ const CategoryPage = () => {
} = useCategoryProducts({ } = useCategoryProducts({
categoryId, categoryId,
collectionId, collectionId,
channelId,
brandId, brandId,
selectedCategory, selectedCategory,
isSubCategory, isSubCategory,
@@ -80,28 +119,64 @@ const CategoryPage = () => {
selectedFilterBrand: filterState.selectedFilterBrand, selectedFilterBrand: filterState.selectedFilterBrand,
minPrice: pageState.minPrice, minPrice: pageState.minPrice,
maxPrice: pageState.maxPrice, maxPrice: pageState.maxPrice,
sorting: pageState.sorting,
searchQuery, searchQuery,
initialProducts: getSavedState("products", []),
initialHasMore: getSavedState("hasMore", true),
}); });
const isMobilePhoneView =
(Number(categoryId) === 531 ||
Number(filterState.selectedFilterCategory) === 531) &&
windowWidth >= 768;
useEffect(() => { useEffect(() => {
if (isInitialMount.current) { if (isInitialMount.current) {
isInitialMount.current = false; isInitialMount.current = false;
prevRouteRef.current = routeKey; prevRouteRef.current = routeKey;
const savedScroll = getSavedState("scroll", 0);
if (savedScroll > 0) {
setTimeout(() => window.scrollTo(0, savedScroll), 100);
}
return; return;
} }
if (prevRouteRef.current === routeKey) return; if (prevRouteRef.current === routeKey && !location.state?.clearFilters) return;
prevRouteRef.current = routeKey; prevRouteRef.current = routeKey;
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([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); }
setPageState({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: "",
});
setFilterState({ setFilterState({
selectedFilterCategory: null, selectedFilterCategory: null,
selectedFilterBrand: null, selectedFilterBrand: null,
brandSearchQuery: "", brandSearchQuery: "",
}); });
window.scrollTo(0, 0);
}
if (location.state?.clearFilters) { if (location.state?.clearFilters) {
navigate(location.pathname, { replace: true, state: {} }); navigate(location.pathname, { replace: true, state: {} });
@@ -109,11 +184,52 @@ const CategoryPage = () => {
}, [ }, [
routeKey, routeKey,
location.state?.clearFilters, location.state?.clearFilters,
location.pathname,
navigate, navigate,
setAllProducts, setAllProducts,
setHasMore, setHasMore,
]); ]);
const stateRef = useRef();
useEffect(() => {
stateRef.current = { routeKey, pageState, filterState, allProducts, hasMore };
}, [routeKey, pageState, filterState, allProducts, hasMore]);
useEffect(() => {
if (stateRef.current) {
try {
const { routeKey: key, pageState: ps, filterState: fs, allProducts: ap, hasMore: hm } = stateRef.current;
sessionStorage.setItem(`category_pageState_${key}`, JSON.stringify(ps));
sessionStorage.setItem(`category_filterState_${key}`, JSON.stringify(fs));
sessionStorage.setItem(`category_products_${key}`, JSON.stringify(ap));
sessionStorage.setItem(`category_hasMore_${key}`, JSON.stringify(hm));
} catch (error) {
console.warn("Could not save category state to sessionStorage", error);
}
}
}, [pageState, filterState, allProducts, hasMore, routeKey]);
useEffect(() => {
let scrollTimeout;
const handleScroll = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
if (stateRef.current) {
try {
sessionStorage.setItem(`category_scroll_${stateRef.current.routeKey}`, JSON.stringify(window.scrollY));
} catch (e) {
// ignore
}
}
}, 100);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
if (scrollTimeout) clearTimeout(scrollTimeout);
};
}, []);
const filteredProducts = useMemo(() => { const filteredProducts = useMemo(() => {
let list = searchQuery ? searchResults : allProducts; let list = searchQuery ? searchResults : allProducts;
@@ -144,7 +260,7 @@ const CategoryPage = () => {
if (filterState.selectedFilterCategory) { if (filterState.selectedFilterCategory) {
const cat = findCategoryById( const cat = findCategoryById(
categoriesData?.data, categoriesData?.data,
filterState.selectedFilterCategory filterState.selectedFilterCategory,
); );
return cat?.name || "Category"; return cat?.name || "Category";
} }
@@ -169,7 +285,12 @@ const CategoryPage = () => {
selectedFilterBrand: null, selectedFilterBrand: null,
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); setPageState((prev) => ({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: prev.sorting,
}));
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
@@ -177,15 +298,15 @@ const CategoryPage = () => {
}; };
const handleFilterCategoryDeselect = () => { const handleFilterCategoryDeselect = () => {
setFilterState((prev) => ({ setFilterState((prev) => ({ ...prev, selectedFilterCategory: null }));
setPageState((prev) => ({
...prev, ...prev,
selectedFilterCategory: null, currentPage: 1,
minPrice: "",
maxPrice: "",
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
if (categoryId) fetchFilters({ category_id: categoryId }); if (categoryId) fetchFilters({ category_id: categoryId });
}; };
@@ -195,32 +316,29 @@ const CategoryPage = () => {
selectedFilterBrand: brandId, selectedFilterBrand: brandId,
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" }); setPageState((prev) => ({
currentPage: 1,
minPrice: "",
maxPrice: "",
sorting: prev.sorting,
}));
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
}; };
const handleFilterBrandDeselect = () => { const handleFilterBrandDeselect = () => {
setFilterState((prev) => ({ setFilterState((prev) => ({ ...prev, selectedFilterBrand: null }));
setPageState((prev) => ({
...prev, ...prev,
selectedFilterBrand: null, currentPage: 1,
minPrice: "",
maxPrice: "",
})); }));
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]); setAllProducts([]);
setHasMore(true); setHasMore(true);
}; };
const handleCategoryClick = (targetId) => { const handleCategoryClick = (targetId) => {
setFilterState({
selectedFilterCategory: null,
selectedFilterBrand: null,
brandSearchQuery: "",
});
setPageState({ currentPage: 1, minPrice: "", maxPrice: "" });
setAllProducts([]);
setHasMore(true);
navigate(`/category/${targetId}`, { navigate(`/category/${targetId}`, {
replace: false, replace: false,
state: { clearFilters: true, timestamp: Date.now() }, state: { clearFilters: true, timestamp: Date.now() },
@@ -256,6 +374,21 @@ const CategoryPage = () => {
return ( return (
<div className={styles.categoryPage}> <div className={styles.categoryPage}>
{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) && ( {(categoryId || filterState.selectedFilterCategory) && (
<CategoryBreadcrumbs <CategoryBreadcrumbs
categoriesData={categoriesData} categoriesData={categoriesData}
@@ -268,38 +401,51 @@ const CategoryPage = () => {
<p className={styles.sum}> <p className={styles.sum}>
{t("category.total")}: {totalItems} {t("category.items")} {t("category.total")}: {totalItems} {t("category.items")}
</p> </p>
</>
<div className={styles.bars}>
<button className={styles.sum}>
<strong>{t("category.total")}:</strong> <br />
{totalItems} {t("category.items")}
</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}> <div className={styles.bars}>
<button
className={styles.filterButton}
onClick={() => setIsFilterDrawerOpen(true)}
>
{t("category.filter")} <LuFilter />
</button>
</div>
<Drawer
title={t("category.filter")}
placement="right"
onClose={() => setIsFilterDrawerOpen(false)}
open={isFilterDrawerOpen}
width="80%"
>
<CategoryFilters <CategoryFilters
filtersData={filtersData} filtersData={filtersData}
selectedFilterCategory={filterState.selectedFilterCategory} selectedFilterCategory={filterState.selectedFilterCategory}
selectedFilterBrand={filterState.selectedFilterBrand} selectedFilterBrand={filterState.selectedFilterBrand}
brandSearchQuery={filterState.brandSearchQuery} brandSearchQuery={filterState.brandSearchQuery}
searchQuery={searchQuery} 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} onCategorySelect={handleFilterCategorySelect}
onCategoryDeselect={handleFilterCategoryDeselect} onCategoryDeselect={handleFilterCategoryDeselect}
onBrandSelect={handleFilterBrandSelect} onBrandSelect={handleFilterBrandSelect}
@@ -307,9 +453,69 @@ const CategoryPage = () => {
onBrandSearchChange={(query) => onBrandSearchChange={(query) =>
setFilterState((prev) => ({ ...prev, brandSearchQuery: query })) setFilterState((prev) => ({ ...prev, brandSearchQuery: query }))
} }
sorting={pageState.sorting}
onSortingChange={(value) => {
setPageState((prev) => {
const newSorting = prev.sorting === value ? "" : value;
setAllProducts([]); // her zaman sıfırla
setHasMore(true);
return { ...prev, sorting: newSorting, currentPage: 1 };
});
}}
/>
</Drawer>
<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}> <main className={styles.productsContainer}>
{(Number(categoryId) === 1136 ||
Number(filterState.selectedFilterCategory) === 1136) && (
<Carconfigurator />
)}
{isInitialLoad ? ( {isInitialLoad ? (
<div className={styles.loaderContainer}> <div className={styles.loaderContainer}>
<Loader /> <Loader />
@@ -320,21 +526,34 @@ const CategoryPage = () => {
next={loadMoreData} next={loadMoreData}
hasMore={hasMore} hasMore={hasMore}
scrollThreshold={0.8} scrollThreshold={0.8}
scrollableTarget={null}
style={{ overflow: "hidden" }}
loader={ loader={
<div className={styles.loaderContainer}> <div className={`${styles.loaderContainer} `}>
<Loader /> <Loader />
</div> </div>
} }
className={styles.productGrid} className={`${styles.productGrid} ${
isMobilePhoneView ? styles.mobilePhoneGrid : ""
}`}
> >
{filteredProducts.map((product) => ( {filteredProducts.map((product) =>
isMobilePhoneView ? (
<MobilePhoneCard
key={product.id}
product={product}
showFavoriteButton
showAddToCart
/>
) : (
<ProductCard <ProductCard
key={product.id} key={product.id}
product={product} product={product}
showFavoriteButton showFavoriteButton
showAddToCart showAddToCart
/> />
))} ),
)}
</InfiniteScroll> </InfiniteScroll>
) : ( ) : (
<div>{t("search.noResults")}</div> <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> <p>Eltip bermek hyzmaty Aşgabat şäheriniň çägi bilen bir hatarda Büzmeýine we Änew şäherine hem elýeterlidir;</p>
</div> </div>
<div className={styles.termItem}> {/* <div className={styles.termItem}>
<p> <p>
Sargydyň iň pes çägi <span className={styles.highlight}>50 manat</span> bolmaly; Sargydyň iň pes çägi <span className={styles.highlight}>50 manat</span> bolmaly;
sargydyňyz <span className={styles.highlight}>150 manatdan</span> geçse eltip bermek hyzmaty mugt; sargydyňyz <span className={styles.highlight}>150 manatdan</span> geçse eltip bermek hyzmaty mugt;
</p> </p>
</div> </div> */}
<div className={styles.termItem}> <div className={styles.termItem}>
<p>Saýtdan sargyt edeniňizden soňra operator size jaň edip sargyt tassyklar (eger hemişelik müşderi bolsaňyz sargytlaryňyz islegiňize göra awtomatik usulda hem tassyklanýar);</p> <p>Saýtdan sargyt edeniňizden soňra operator size jaň edip sargyt tassyklar (eger hemişelik müşderi bolsaňyz sargytlaryňyz islegiňize göra awtomatik usulda hem tassyklanýar);</p>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
// ─── Breadcrumb ───────────────────────────────────────────────────
.breadcrumb { .breadcrumb {
margin-bottom: 15px; margin-bottom: 15px;
color: #666; color: #666;
@@ -19,76 +20,274 @@
} }
} }
// ─── Product section: 3 kolon ─────────────────────────────────────
// desktop: [image 35%] | [info+description flex:1] | [purchase 260px]
// tablet: [image 45%] [info 55%] / [purchase full-width]
// mobile: tek kolon
.productSection { .productSection {
display: flex; display: flex;
gap: 24px; gap: 24px;
background-color: rgb(255, 255, 255); align-items: flex-start;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); // background-color: #fff;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: 4px;
padding: 1.25rem; // padding: 1.25rem;
box-sizing: border-box; box-sizing: border-box;
@media screen and (max-width: 900px) {
flex-wrap: wrap;
}
@media screen and (max-width: 639px) { @media screen and (max-width: 639px) {
flex-direction: column; flex-direction: column;
padding: 0.75rem; border-radius: 8px;
// padding: 0.75rem;
} }
} }
// ─── Sol: resim kolonu ────────────────────────────────────────────
.productImage { .productImage {
background: #fff; background: #fff;
padding: 20px; // padding: 20px;
border-radius: 8px; border-radius: 8px;
width: 40%; width: 36%;
flex-shrink: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
padding: 5px; width: 45%;
border-radius: 8px;
// padding: 5px;
} }
@media screen and (max-width: 639px) { @media screen and (max-width: 639px) {
width: 100%; width: 100%;
padding: 0;
border-radius: 8px;
} }
img { img {
width: 99%; width: 99%;
height: auto; height: auto;
object-fit: contain; object-fit: contain;
// border: 1px solid #eee;
@media screen and (max-width: 900px) {
height: 100%;
}
} }
} }
// ─── Orta: isim + meta + description kolonu ───────────────────────
.productInfo { .productInfo {
width: 60%; 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) { @media screen and (max-width: 639px) {
width: 100%; width: 100%;
}
@media screen and (max-width: 520px) { // mobile'da purchase card orta kolona taşınır (sticky bar var)
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
} }
.productTitle { .productTitle {
font-size: 30px; font-size: 24px;
font-weight: 600; font-weight: 600;
margin-bottom: 12px; margin: 0 0 4px;
color: #000000;
}
.productDescription {
font-size: 14px;
color: #000; color: #000;
margin-bottom: 24px; line-height: 1.3;
} }
} }
.productMeta { // ─── Sağ: satın alma kartı kolonu ────────────────────────────────
background: #f5f5f5; .purchaseCol {
// padding: 16px; 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; border-radius: 8px;
margin-bottom: 24px; 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;
}
}
}
// ─── 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;
}
.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 {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px 20px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
gap: 12px;
display: flex;
flex-direction: column;
.metaItem { .metaItem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 8px 16px; padding-bottom: 8px;
border-bottom: 2px solid #ffffff; border-bottom: 1px solid #f1f1f1;
text-decoration: none;
color: inherit;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -98,112 +297,180 @@
.metaLabel { .metaLabel {
color: #000; color: #000;
font-size: 14px; font-size: 14px;
font-weight: 600;
} }
.metaValue { .metaValue {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 600;
}
}
.Btn {
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 { // ─── 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) { @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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid #e5e7eb; position: sticky;
bottom: 59px;
z-index: 50;
background: #fff;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
background-color: #fff; border-bottom: 1px solid #e5e7eb;
padding: 15px 16px; padding: 10px 16px;
@media screen and (max-width: 520px) { box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
border-bottom: none; gap: 12px;
border-top: none; border-radius: 8px;
padding: 0; margin-top: 8px;
} }
}
.mobilePriceContainer {
display: flex;
flex-direction: column;
gap: 2px;
width: 45%;
.price { .price {
font-size: 24px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: #000; color: #000;
@media screen and (max-width: 520px) {
font-size: 20px;
}
} }
.oldPrice { .oldPrice {
font-size: 16px; font-size: 13px;
color: #d32824; color: #d32824;
text-decoration: line-through; text-decoration: line-through;
font-weight: 600; font-weight: 500;
@media screen and (max-width: 520px) {
font-size: 14px;
}
} }
} }
.favoriteButton { .mobileBtnContainer {
height: 36px;
display: flex; display: flex;
// margin-right: 0.5rem; gap: 8px;
justify-content: center; width: 55%;
align-items: center;
border-radius: 0.375rem;
background-color: rgb(255 255 255);
border: 1px solid rgb(237 228 255);
svg {
fill: #888888;
height: 20px;
width: 20px;
}
}
.wishlistButton {
background: #fff;
border: 1px solid #ddd;
&:hover {
background: #f5f5f5;
}
}
@media (max-width: 768px) {
.productGrid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
} }
// ─── Similar products ─────────────────────────────────────────────
.similarProducts { .similarProducts {
margin-top: 48px; margin-top: 40px;
.sectionTitle { .sectionTitle {
font-size: 20px; font-size: 20px;
@@ -216,117 +483,27 @@
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px; gap: 24px;
@media screen and (max-width: 1230px) { @media screen and (max-width: 1230px) {
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
} }
@media screen and (max-width: 1023px) { @media screen and (max-width: 1023px) {
grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(228px, 1fr));
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(234px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(234px, 1fr));
gap: 10px; gap: 10px;
} }
@media screen and (max-width: 767px) {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
@media screen and (max-width: 510px) { @media screen and (max-width: 510px) {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
} }
} }
@media (max-width: 768px) { // ─── Misc ─────────────────────────────────────────────────────────
.productSection {
grid-template-columns: 1fr;
}
}
.addToCartButton {
// height: 40px;
display: flex;
padding-left: 0.5rem;
padding-right: 0.5rem;
justify-content: center;
align-items: center;
border-radius: 0.25rem;
border-width: 1px;
width: 100%;
min-width: 158px;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
transition-duration: 150ms;
background-color: #d32824;
border: none;
@media screen and (max-width: 639px) {
min-width: auto;
}
svg {
fill: #fff;
width: 20px;
height: 20px;
}
&:hover {
background-color: #e86064;
cursor: pointer;
}
}
.quantityControls {
min-width: 158px;
display: flex;
align-items: center;
gap: 2.5rem;
background-color: #d32824;
// width: 10rem;
// justify-content: center;
border-radius: 5px;
width: 100%;
@media screen and (max-width: 520px) {
min-width: auto;
gap: 0;
}
span {
color: #fff;
font-weight: 700;
font-size: 16px;
display: flex;
width: 100%;
justify-content: center;
}
.quantityBtn {
width: 100%;
height: 100%;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
svg {
fill: #fff;
width: 20px;
height: 20px;
}
&:hover {
background: #e86064;
}
}
}
.outOfStock {
background-color: #ff4d4f;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modalButton { .modalButton {
// Style for modal buttons
padding: 6px 15px; padding: 6px 15px;
background-color: #1890ff; background-color: #1890ff;
color: white; color: white;
@@ -334,3 +511,21 @@
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.wishlistButton {
background: #fff;
border: 1px solid #ddd;
&:hover {
background: #f5f5f5;
}
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.outOfStock {
background-color: #ff4d4f;
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import styles from "./ProductPage.module.scss"; import styles from "./ProductPage.module.scss";
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io"; import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
import { FaShoppingCart } from "react-icons/fa"; import { FaShoppingCart } from "react-icons/fa";
@@ -12,21 +12,24 @@ import {
import ReviewSection from "../../components/Review/index"; import ReviewSection from "../../components/Review/index";
import { Modal } from "antd"; import { Modal } from "antd";
import { debounce } from "lodash";
import { import {
useAddFavoriteMutation, useAddFavoriteMutation,
useRemoveFavoriteMutation, useRemoveFavoriteMutation,
} from "../../app/api/favoritesApi"; } from "../../app/api/favoritesApi";
import { useGetFavoritesQuery } from "../../app/api/favoritesApi"; import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
import { useCart } from "../../app/api/useCart";
import { import {
useAddToCartMutation, useAddToCartMutation,
useUpdateCartItemMutation, useUpdateCartItemMutation,
useRemoveFromCartMutation, useRemoveFromCartMutation,
useGetCartQuery,
} from "../../app/api/cartApi"; } from "../../app/api/cartApi";
import ImageCarousel from "../../components/ProductCard/imageCarousel/index"; import ImageCarousel from "../../components/ProductCard/imageCarousel/index";
import Loader from "../../components/Loader/index"; import Loader from "../../components/Loader/index";
import { Result, Button } from "antd"; import { Result, Button } from "antd";
import { div } from "framer-motion/client";
import PendingPriceBadge from "../../components/PendingPriceBadge";
const isPriceZero = (price) => !price || parseFloat(price) === 0;
const ProductPage = ({ const ProductPage = ({
productProp, productProp,
@@ -39,62 +42,128 @@ const ProductPage = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { productId } = useParams(); const { productId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
data: productResponse, data: productResponse,
error: productError, error: productError,
isLoading: productLoading, isLoading: productLoading,
} = useGetProductByIdQuery(productId); } = useGetProductByIdQuery(productId);
const { const {
data: similarProductsResponse, data: similarProductsResponse,
error: similarProductsError, error: similarProductsError,
isLoading: similarProductsLoading, isLoading: similarProductsLoading,
} = useGetRelatedProductsQuery(productId); } = useGetRelatedProductsQuery(productId);
const [quantity, setQuantity] = useState(0);
const product = productResponse?.data; const product = productResponse?.data;
const similarProducts = similarProductsResponse?.data; const similarProducts = similarProductsResponse?.data;
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false); const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
const [addFavorite] = useAddFavoriteMutation(); const [addFavorite] = useAddFavoriteMutation();
const [removeFavorite] = useRemoveFavoriteMutation(); const [removeFavorite] = useRemoveFavoriteMutation();
const { data: favoriteProducts = [], refetch } = useGetFavoritesQuery(); const { data: favoriteProducts = [] } = useGetFavoritesQuery();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [localIsFavorite, setLocalIsFavorite] = useState( const [localIsFavorite, setLocalIsFavorite] = useState(
favoriteProducts.some((fav) => fav.product?.id === product?.id), favoriteProducts.some((fav) => fav.product?.id === product?.id),
); );
const { data: cartData } = useGetCartQuery(undefined, {
selectFromResult: (result) => ({ const [isDescExpanded, setIsDescExpanded] = useState(false);
data: result.data, 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 [addToCart] = useAddToCartMutation();
const [updateCartItem] = useUpdateCartItemMutation(); const [updateCartItem] = useUpdateCartItemMutation();
const [removeFromCart] = useRemoveFromCartMutation(); const [removeFromCart] = useRemoveFromCartMutation();
const [localQuantity, setLocalQuantity] = useState(0); 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); const [pendingQuantity, setPendingQuantity] = useState(0);
useEffect(() => { const cartItem = getCartItem(product?.id || productId);
if (cartItem) {
setLocalQuantity(cartItem.quantity || cartItem.product_quantity || 0);
setPendingQuantity(cartItem.quantity || cartItem.product_quantity || 0);
} else {
setLocalQuantity(0);
setPendingQuantity(0);
}
}, [cartData, cartItem]);
// ✅ 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(() => { useEffect(() => {
if (Array.isArray(favoriteProducts)) { if (Array.isArray(favoriteProducts)) {
const isFav = favoriteProducts.some( const isFav = favoriteProducts.some(
@@ -104,78 +173,55 @@ const ProductPage = ({
} }
}, [favoriteProducts, product?.id]); }, [favoriteProducts, product?.id]);
// ✅ Toggle Favorite
const handleToggleFavorite = async (event) => { const handleToggleFavorite = async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isLoading) return; if (isLoading) return;
setIsLoading(true); setIsLoading(true);
const originalState = localIsFavorite;
setLocalIsFavorite(!originalState); // Optimistic Update
try { try {
if (localIsFavorite) { if (originalState) {
const result = await removeFavorite(product.id).unwrap(); await removeFavorite(product.id).unwrap();
if (result === "Removed" || result?.status === "success") {
setLocalIsFavorite(false);
}
} else { } else {
const result = await addFavorite(product.id).unwrap(); await addFavorite(product.id).unwrap();
if (result === "Added" || result?.status === "success") {
setLocalIsFavorite(true);
} }
}
// Refetch after changing favorite status
await refetch();
} catch (error) { } catch (error) {
console.error("Failed to toggle favorite:", error); console.error("Failed to toggle favorite:", error);
setLocalIsFavorite(originalState); // Rollback
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// ✅ Add to Cart (Initial)
const handleAddToCart = async (event) => { const handleAddToCart = async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
// Check if stock is available
if (product.stock <= 0) { if (product.stock <= 0) {
setStockErrorModalVisible(true); setStockErrorModalVisible(true);
return; return;
} }
setLocalQuantity((prev) => prev + 1); setLocalQuantity(1);
setPendingQuantity((prev) => prev + 1); setPendingQuantity(1);
try { try {
await addToCart({ productId: product.id, quantity: 1 }).unwrap(); await addToCart({ productId: product.id, quantity: 1 }).unwrap();
} catch (error) { } catch (error) {
console.error("Failed to add to cart:", error); console.error("Failed to add to cart:", error);
setLocalQuantity((prev) => prev - 1); setLocalQuantity(0);
setPendingQuantity((prev) => prev - 1); 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) => { const handleQuantityIncrease = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isLoading) return; if (isLoading) return;
if (localQuantity >= product.stock) { if (localQuantity >= product.stock) {
@@ -187,11 +233,9 @@ const ProductPage = ({
setPendingQuantity((prev) => prev + 1); setPendingQuantity((prev) => prev + 1);
}; };
// Update quantity decrease handler
const handleQuantityDecrease = (event) => { const handleQuantityDecrease = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isLoading) return; if (isLoading) return;
if (pendingQuantity <= 1) { if (pendingQuantity <= 1) {
@@ -200,6 +244,9 @@ const ProductPage = ({
setIsLoading(true); setIsLoading(true);
removeFromCart({ productId: product.id }) removeFromCart({ productId: product.id })
.unwrap() .unwrap()
.then(() => {
// Success handled by hook
})
.catch(() => { .catch(() => {
setLocalQuantity(1); setLocalQuantity(1);
setPendingQuantity(1); setPendingQuantity(1);
@@ -213,9 +260,18 @@ const ProductPage = ({
} }
}; };
// ✅ Debounced Cart Update
useEffect(() => { useEffect(() => {
const updateCart = async () => { const serverQty = parseInt(
if (pendingQuantity !== quantity && pendingQuantity > 0) { cartItem?.quantity || cartItem?.product_quantity || 0,
10,
);
if (pendingQuantity === serverQty || pendingQuantity <= 0) {
return;
}
const handler = setTimeout(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await updateCartItem({ await updateCartItem({
@@ -224,22 +280,15 @@ const ProductPage = ({
}).unwrap(); }).unwrap();
} catch (error) { } catch (error) {
console.error("Failed to update cart item:", error); console.error("Failed to update cart item:", error);
setLocalQuantity(quantity); setLocalQuantity(serverQty);
setPendingQuantity(quantity); setPendingQuantity(serverQty);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} }, 500);
};
const debouncedUpdate = debounce(updateCart, 300); return () => clearTimeout(handler);
}, [pendingQuantity, cartItem, product?.id, updateCartItem]);
if (pendingQuantity !== quantity) {
debouncedUpdate();
}
return () => debouncedUpdate.cancel();
}, [pendingQuantity, quantity, product, updateCartItem]);
if (productLoading || similarProductsLoading) return <Loader />; if (productLoading || similarProductsLoading) return <Loader />;
if (productError || similarProductsError) if (productError || similarProductsError)
@@ -258,12 +307,74 @@ const ProductPage = ({
if (!product) return <div>Can not find product</div>; if (!product) return <div>Can not find product</div>;
const imageUrl = product.media?.[0]?.thumbnail || "";
const categoryName = product.categories?.[0]?.name || "Category"; const categoryName = product.categories?.[0]?.name || "Category";
const categoryId = product.categories?.[0]?.id; const categoryId = product.categories?.[0]?.id;
const handleCategoryClick = (categoryId) => { const handleCategoryClick = (categoryId) => {
navigate(`/category/${categoryId}`); navigate(`/category/${categoryId}`);
}; };
// ── Cart + favorite butonları (desktop purchase card + mobile bar'da ortak) ──
const CartButtons = () => (
<div className={styles.Btn}>
{showFavoriteButton && (
<button
className={styles.favoriteButton}
onClick={handleToggleFavorite}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
onClick={handleQuantityDecrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
fill="white"
/>
</svg>
</button>
<span>{localQuantity}</span>
<button
onClick={handleQuantityIncrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
fill="white"
/>
</svg>
</button>
</div>
) : (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
>
<FaShoppingCart />
</button>
)}
</>
)}
</div>
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -275,9 +386,10 @@ const ProductPage = ({
<span>{product?.name || "Product"}</span> <span>{product?.name || "Product"}</span>
</div> </div>
{/* Product Details */} {/* ── 3 kolon ana section ── */}
<div className={styles.productSection}> <div className={styles.productSection}>
<div className={styles.productImage}> {/* KOLON 1: Resim */}
<div className={styles.productImage} ref={imageColRef}>
<ImageCarousel <ImageCarousel
images={product.media} images={product.media}
altText={product.name} altText={product.name}
@@ -285,172 +397,165 @@ const ProductPage = ({
isDetailView={true} isDetailView={true}
/> />
</div> </div>
<div className={styles.productInfo}>
<h1 className={styles.productTitle}>{product.name}</h1>
<p
className={styles.productDescription}
dangerouslySetInnerHTML={{ __html: product.description }}
></p>
{/* KOLON 2: İsim + Meta + Description */}
<div className={styles.productInfo} ref={productInfoRef}>
{/* Meta tablo */}
<div className={styles.productMeta}> <div className={styles.productMeta}>
<div className={styles.metaItem}> <h1 className={styles.productTitle}>{product.name}</h1>
{/* <div className={styles.metaItem}>
<span className={styles.metaLabel}> <span className={styles.metaLabel}>
{t("product.productCode")} {t("product.productCode")}
</span> </span>
<span className={styles.metaValue}>{product.id}</span> <span className={styles.metaValue}>{product.id}</span>
</div> </div>
{product.barcode && ( {product.barcode && (
<div className={styles.metaItem}> <div className={styles.metaItem}>
<span className={styles.metaLabel}>{t("product.barCode")}</span> <span className={styles.metaLabel}>{t("product.barCode")}</span>
<span className={styles.metaValue}>{product.barcode}</span> <span className={styles.metaValue}>{product.barcode}</span>
</div> </div>
)} )} */}
{product.brand?.name && ( {product.brand?.name && (
<div className={styles.metaItem}> <a
href={`/brands/${product.brand.id}`}
target="_blank"
className={styles.metaItem}
>
<span className={styles.metaLabel}>{t("order.brand")}</span> <span className={styles.metaLabel}>{t("order.brand")}</span>
<span className={styles.metaValue}>{product.brand.name}</span> <span className={styles.metaValue}>{product.brand.name}</span>
</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>
{/* 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>
)} )}
</div> </div>
<div className={styles.productActions}>
<div className={styles.priceContainer}>
<span className={styles.price}>{product.price_amount} m.</span>
{/* 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 && ( {product.old_price_amount && (
<span className={styles.oldPrice}> <span className={styles.oldPrice}>
{product.old_price_amount} m. {product.old_price_amount} m.
</span> </span>
)} )}
</div>
<div className={styles.Btn}>
{showFavoriteButton && (
<button
className={styles.favoriteButton}
onClick={handleToggleFavorite}
>
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
</button>
)}
{showAddToCart && (
<>
{localQuantity > 0 ? (
<div className={styles.quantityControls}>
<button
onClick={handleQuantityDecrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
fill="white"
></path>
</svg>
</button>
<span>{localQuantity}</span>
<button
onClick={handleQuantityIncrease}
className={styles.quantityBtn}
>
<svg
viewBox="0 0 9 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
fill="white"
></path>
</svg>
</button>
</div>
) : (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
>
<FaShoppingCart />
</button>
)}
</> </>
)} )}
</div> </div>
</div> </div>
<div
className={styles.productActionsMobile} {/* Butonlar */}
style={{ position: "sticky", bottom: "59px" }} <CartButtons />
> </div>
<div className={styles.priceContainer}> </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> <span className={styles.price}>{product.price_amount} m.</span>
{product.old_price_amount && (
<span className={styles.oldPrice}> <span className={styles.oldPrice}>
{product.old_price_amount} m. {product.old_price_amount} m.
</span> </span>
</div>
<div className={styles.Btn}>
{showFavoriteButton && (
<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.mobileBtnContainer}>
<CartButtons />
</div> </div>
</div> </div>
</div>
{/* Reviews */}
<ReviewSection <ReviewSection
productId={productId} productId={productId}
existingReviews={product.reviews_resources} existingReviews={product.reviews_resources}
@@ -476,6 +581,7 @@ const ProductPage = ({
</div> </div>
</div> </div>
{/* Stock modal */}
<Modal <Modal
title={t("common.warning")} title={t("common.warning")}
open={stockErrorModalVisible} open={stockErrorModalVisible}
@@ -504,3 +610,4 @@ const ProductPage = ({
}; };
export default ProductPage; export default ProductPage;

View File

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

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

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

View File

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

View File

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

View File

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