refactor: move spa to frontend folder

This commit is contained in:
2025-10-27 12:32:38 +03:00
parent 617b5491a1
commit 5681ac592a
77 changed files with 13 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
{
"hash": "db53d5f1",
"configHash": "861000cb",
"lockfileHash": "e3b0c442",
"browserHash": "84fd2900",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -1,5 +0,0 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -1,384 +0,0 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "tg-mini-app-shop",
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0",
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^11.2.10",
"vue": "^3.5.17",
"vue-imask": "^7.6.1",
"vue-router": "^4.5.1",
"vue-tg": "^0.9.0-beta.10",
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.21",
"daisyui": "^5.0.46",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"terser": "^5.36.0",
"vite": "^7.1.11",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.28.4", "", { "dependencies": { "core-js-pure": "^3.43.0" } }, "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@heroicons/vue": ["@heroicons/vue@2.2.0", "", { "peerDependencies": { "vue": ">= 3" } }, "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.29", "", {}, "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.16", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.16" } }, "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.16", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.16", "@tailwindcss/oxide-darwin-arm64": "4.1.16", "@tailwindcss/oxide-darwin-x64": "4.1.16", "@tailwindcss/oxide-freebsd-x64": "4.1.16", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", "@tailwindcss/oxide-linux-x64-musl": "4.1.16", "@tailwindcss/oxide-wasm32-wasi": "4.1.16", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.16", "", { "os": "android", "cpu": "arm64" }, "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16", "", { "os": "linux", "cpu": "arm" }, "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.16", "", { "os": "linux", "cpu": "x64" }, "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.16", "", { "os": "linux", "cpu": "x64" }, "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.16", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.16", "", { "os": "win32", "cpu": "x64" }, "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.16", "", { "dependencies": { "@tailwindcss/node": "4.1.16", "@tailwindcss/oxide": "4.1.16", "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.29" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.25" } }, "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/shared": "3.5.22", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.22", "", { "dependencies": { "@vue/compiler-core": "3.5.22", "@vue/shared": "3.5.22" } }, "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/compiler-core": "3.5.22", "@vue/compiler-dom": "3.5.22", "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22", "estree-walker": "^2.0.2", "magic-string": "^0.30.19", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.22", "", { "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/shared": "3.5.22" } }, "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.7", "", { "dependencies": { "@vue/devtools-kit": "^7.7.7" } }, "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg=="],
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.7", "", { "dependencies": { "@vue/devtools-shared": "^7.7.7", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA=="],
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.7", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.22", "", { "dependencies": { "@vue/shared": "3.5.22" } }, "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.22", "", { "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" } }, "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.22", "", { "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", "@vue/shared": "3.5.22", "csstype": "^3.1.3" } }, "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.22", "", { "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" }, "peerDependencies": { "vue": "3.5.22" } }, "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ=="],
"@vue/shared": ["@vue/shared@3.5.22", "", {}, "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w=="],
"@vueuse/core": ["@vueuse/core@13.9.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA=="],
"@vueuse/metadata": ["@vueuse/metadata@13.9.0", "", {}, "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg=="],
"@vueuse/shared": ["@vueuse/shared@13.9.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-i+sRXGhz4+QW8aACZ3+r1GAKMt0wlFpeA8M5rOQd0HEYw9zhDrlx9Wc8uQ0IdXakjJRthzglEwfB/yqIjO6iDg=="],
"birpc": ["birpc@2.6.1", "", {}, "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ=="],
"browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="],
"core-js-pure": ["core-js-pure@3.45.1", "", {}, "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ=="],
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"daisyui": ["daisyui@5.3.10", "", {}, "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.1", "", {}, "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"imask": ["imask@7.6.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.24.4" } }, "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="],
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
"ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "^2.0.3", "node-fetch-native": "^1.6.4", "ufo": "^1.5.4" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pinia": ["pinia@3.0.3", "", { "dependencies": { "@vue/devtools-api": "^7.7.2" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="],
"swiper": ["swiper@11.2.10", "", {}, "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ=="],
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"vite": ["vite@7.1.12", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug=="],
"vue": ["vue@3.5.22", "", { "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", "@vue/runtime-dom": "3.5.22", "@vue/server-renderer": "3.5.22", "@vue/shared": "3.5.22" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-imask": ["vue-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "vue-demi": "^0.14.7" }, "peerDependencies": { "vue": ">=2.7" } }, "sha512-/5ZVNerI9Dn6gZ/cSCYGiZK4JHdwsEBgHBTRpVwS2U0URxK/Jt5FlQuoL1DhbxC6t4ElcVMWYOvkE2hR8hdt1w=="],
"vue-router": ["vue-router@4.6.3", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg=="],
"vue-tg": ["vue-tg@0.9.0", "", { "peerDependencies": { "vue": "^3" } }, "sha512-CqIpKFj8/KPBSecsdyuaLm3pA09YCpjHhe/rM7tDEAa8dpLYo1zTULDMczlh2JgVcX06xzJEbSe/OAcoW1Tl7Q=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
}
}

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>OpenCart Telegram Mini App</title>
</head>
<body>
<div id="app"></div>
<div id="app-error"></div>
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,34 +0,0 @@
{
"name": "tg-mini-app-shop",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^13.9.0",
"crypto-js": "^4.2.0",
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^11.2.10",
"vue": "^3.5.22",
"vue-imask": "^7.6.1",
"vue-router": "^4.6.3",
"vue-tg": "^0.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.21",
"daisyui": "^5.3.10",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"terser": "^5.44.0",
"vite": "^7.1.12"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,113 +0,0 @@
<template>
<div class="drawer h-full">
<input id="app-drawer" type="checkbox" class="drawer-toggle" v-model="drawerOpen" />
<div class="drawer-content">
<div class="app-container h-full">
<header class="app-header w-full" v-if="platform === 'ios'"></header>
<Navbar @drawer="toggleDrawer"/>
<section class="telecart-main-section">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'" />
<AppDebugMessage v-if="settings.app_debug"/>
<RouterView v-slot="{ Component, route }">
<KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</RouterView>
<CartButton v-if="settings.store_enabled" />
<Dock v-if="isAppDockShown" />
</section>
</div>
</div>
<div class="drawer-side z-50 safe-top">
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 text-base-content min-h-full w-80 p-4">
<li><a href="#">🏠 Главная</a></li>
<li><a href="#">🛒 Корзина</a></li>
<li><a @click="drawerOpen = false"> Закрыть</a></li>
</ul>
</div>
</div>
</template>
<script setup>
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import {useWebAppViewport} from 'vue-tg';
import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import CartButton from "@/components/CartButton.vue";
import Dock from "@/components/Dock.vue";
import Navbar from "@/components/Navbar.vue";
import AppDebugMessage from "@/components/AppDebugMessage.vue";
const tg = useMiniApp();
const platform = ref();
platform.value = tg.platform;
const {disableVerticalSwipes} = useWebAppViewport();
disableVerticalSwipes();
const router = useRouter();
const route = useRoute();
const settings = useSettingsStore();
const filtersStore = useProductFiltersStore();
const backButton = window.Telegram.WebApp.BackButton;
const haptic = window.Telegram.WebApp.HapticFeedback;
const drawerOpen = ref(false);
const routesToHideAppDock = [
'product.show',
'checkout',
'order_created',
'filters',
];
const isAppDockShown = computed(() => {
return routesToHideAppDock.indexOf(route.name) === -1;
});
function navigateBack() {
haptic.impactOccurred('light');
router.back();
}
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value
}
watch(
() => route.name,
() => {
if (route.name === 'home') {
backButton.hide();
backButton.offClick(navigateBack);
} else {
backButton.show();
backButton.onClick(navigateBack);
}
},
{immediate: true}
);
function handleClickOutside(e) {
if (!e.target.closest('input,select,textarea')) {
document.activeElement?.blur();
}
}
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
</script>

View File

@@ -1,7 +0,0 @@
<template>
<LoadingFullScreen text="Загрузка приложения..."/>
</template>
<script setup>
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
</script>

View File

@@ -1,20 +0,0 @@
<template>
<div style="z-index: 99999" class="fixed top-0 left-0 w-full h-full bg-base-100">
<div class="flex flex-col items-center justify-center h-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-20">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<h1 class="font-semibold text-2xl mb-2">Магазин временно недоступен</h1>
<p class="text-sm text-muted">Мы на перерыве, скоро всё снова заработает 🛠</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
error: Error,
});
</script>

View File

@@ -1,63 +0,0 @@
import {reactive} from "vue";
class ShoppingCart {
constructor() {
this.items = reactive([]);
this.storageKey = 'shoppingCart';
this.storage = Telegram.WebApp.DeviceStorage;
this._load()
.then(items => {
this.items = items;
console.log(items);
})
.catch(error => console.log(error));
}
async addItem(productId, productName, quantity, options = {}) {
this.items.push({ productId: productId, productName: productName, quantity, options });
this._save(this.items);
}
has(productId) {
const item = this.getItem(productId);
console.log(item);
return this.getItem(productId) !== null;
}
getItem(productId) {
return this.items.find(item => item.productId === productId) ?? null;
}
getItems() {
return this.items;
}
clear() {
this.storage.deleteItem(this.storageKey)
}
async _load() {
return new Promise((resolve, reject) => {
this.storage.getItem(this.storageKey, (error, value) => {
if (error) {
console.error(error);
reject([]);
}
try {
resolve(value ? JSON.parse(value) : []);
} catch (error) {
console.error(error);
reject([]);
}
});
});
}
_save(items) {
this.storage.setItem(this.storageKey, JSON.stringify(items));
}
}
export default ShoppingCart;

View File

@@ -1,9 +0,0 @@
<template>
<div role="alert" class="alert alert-warning rounded-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Включен режим разработчика!</span>
</div>
</template>

View File

@@ -1,60 +0,0 @@
<template>
<div v-if="slides.length > 0" class="app-banner px-4">
<Swiper
class="select-none"
:slides-per-view="1"
:space-between="50"
pagination
:pagination="{ clickable: true }"
@swiper="onSwiper"
@slideChange="onSlideChange"
>
<SwiperSlide v-for="slide in slides" :key="slide.id">
<img :src="slide.image" :alt="slide.title">
</SwiperSlide>
</Swiper>
</div>
</template>
<script setup>
import {Swiper, SwiperSlide} from 'swiper/vue';
import 'swiper/css';
import 'swiper/css/navigation';
import {onMounted, ref} from "vue";
import {fetchBanner} from "@/utils/ftch.js";
const slides = ref([]);
const onSwiper = (swiper) => {
console.log(swiper);
};
const onSlideChange = () => {
console.log('slide change');
};
onMounted(async () => {
const response = await fetchBanner();
slides.value = response.data;
})
</script>
<style>
.app-banner {
overflow: hidden;
}
.app-banner .swiper-horizontal > .swiper-pagination-bullets {
position: relative;
bottom: 0;
}
.app-banner .swiper-horizontal .swiper-slide {
display: flex;
align-items: center;
justify-content: center;
}
.app-banner .swiper-horizontal .swiper-slide > img {
border-radius: var(--radius-box);
}
</style>

View File

@@ -1,56 +0,0 @@
<template>
<transition name="fade">
<div
v-if="visible"
class="fixed inset-0 bg-black/50 z-40"
@click.self="close"
>
<transition name="slide-up">
<div
class="fixed bottom-0 left-0 w-full h-[80vh] bg-white rounded-t-2xl shadow-xl z-50 p-4"
>
<slot />
</div>
</transition>
</div>
</transition>
</template>
<script setup>
import {computed} from "vue";
const props = defineProps({
modelValue: Boolean,
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
function close() {
visible.value = false
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.25s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>

View File

@@ -1,5 +0,0 @@
<template>
<div class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<slot></slot>
</div>
</template>

View File

@@ -1,45 +0,0 @@
<template>
<div v-if="isCartBtnShow" class="fixed right-2 bottom-30 z-50 opacity-90">
<div class="indicator">
<span class="indicator-item indicator-top indicator-start badge badge-secondary">{{ cart.productsCount }}</span>
<button class="btn btn-primary btn-xl btn-circle" @click="openCart">
<span v-if="cart.isLoading" class="loading loading-spinner"></span>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</template>
</button>
</div>
</div>
</template>
<script setup>
import {computed, onMounted} from "vue";
import {useCartStore} from "@/stores/CartStore.js";
import {useRoute, useRouter} from "vue-router";
const cart = useCartStore();
const router = useRouter();
const route = useRoute();
const isCartBtnShow = computed(() => {
return route.name === 'product.show';
});
function openCart() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
router.push({name: 'cart'});
}
onMounted(async () => {
await cart.getProducts();
});
</script>
<style scoped>
</style>

View File

@@ -1,28 +0,0 @@
<template>
<div class="flex items-center justify-center p-5 gap-2 flex-wrap">
<RouterLink class="btn btn-md" to="/categories">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
Каталог
</RouterLink>
<RouterLink
v-for="category in categoriesStore.topCategories"
class="btn btn-md max-w-[12rem]"
:to="{name: 'product.categories.show', params: {category_id: category.id}}"
@click="onCategoryClick"
>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{{ category.name }}</span>
</RouterLink>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
const categoriesStore = useCategoriesStore();
function onCategoryClick() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
}
</script>

View File

@@ -1,27 +0,0 @@
<template>
<a
href="#"
:key="category.id"
class="py-2 px-4 flex items-center mb-3"
@click.prevent="$emit('onSelect', category)"
>
<div class="avatar">
<div class="w-8 h-8 rounded">
<img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
</div>
</div>
<h3 class="ml-5 text-lg line-clamp-2">{{ category.name }}</h3>
</a>
</template>
<script setup>
const props = defineProps({
category: {
type: Object,
required: true,
}
});
const emit = defineEmits(["onSelect"]);
</script>

View File

@@ -1,100 +0,0 @@
<template>
<div class="telecart-dock fixed bottom-0 w-full z-50 px-10">
<div
class="telecart-dock-inner flex justify-around items-center bg-base-300/10 h-full backdrop-blur-md border-base-300/90 border">
<RouterLink
:to="{name: 'home'}"
:class="{'active': route.name === 'home'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt">
<polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10"
stroke-width="2"></polyline>
<path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor"
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path>
<line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square"
stroke-miterlimit="10" stroke-width="2"></line>
</g>
</svg>
<span class="dock-label">Главная</span>
</RouterLink>
<RouterLink
:to="{name: 'search'}"
:class="{'active': route.name === 'search'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
</svg>
<span class="dock-label">Поиск</span>
</RouterLink>
<RouterLink
v-if="settings.store_enabled"
:to="{name: 'cart'}"
:class="{'active': route.name === 'cart'}"
class="telecart-dock-item"
@click="onDockItemClick"
>
<div class="indicator">
<span class="indicator-item indicator-end badge badge-secondary badge-xs">{{ cart.productsCount }}</span>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
</svg>
</div>
<span class="dock-label">Корзина</span>
</RouterLink>
</div>
</div>
</template>
<script setup>
import {useRoute} from "vue-router";
import {useCartStore} from "@/stores/CartStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
const route = useRoute();
const cart = useCartStore();
const settings = useSettingsStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
function onDockItemClick() {
haptic.selectionChanged();
}
</script>
<style scoped>
.telecart-dock {
padding-bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 5px);
height: calc(70px + var(--tg-safe-area-inset-bottom, 0px));
}
.telecart-dock-inner {
border-radius: var(--radius-field);
border-width: var(--border);
border-style: solid;
}
.telecart-dock-item {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-field);
padding: 5px 13px;
min-width: 90px;
}
.telecart-dock-item.active {
background-color: var(--color-primary);
backdrop-filter: blur(var(--blur-sm));
color: var(--color-primary-content);
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<fieldset class="fieldset mb-0">
<input
:type="type"
:inputmode="inputMode"
class="input input-lg w-full"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
:maxlength="maxlength"
/>
<p v-if="error" class="label text-error">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
import {computed} from "vue";
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
type: {
type: String,
default: 'text',
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
const inputMode = computed(() => {
switch (props.type) {
case 'email': return 'email';
case 'tel': return 'tel';
case 'number': return 'numeric';
default: return 'text';
}
});
</script>

View File

@@ -1,36 +0,0 @@
<template>
<fieldset class="fieldset mb-0">
<textarea
class="input input-lg w-full h-50"
:class="error ? 'input-error' : ''"
:placeholder="placeholder"
v-model="model"
@input="$emit('clearError')"
rows="8"
:maxlength="maxlength"
/>
<p v-if="error" class="label">{{ error }}</p>
</fieldset>
</template>
<script setup lang="ts">
const model = defineModel();
const props = defineProps({
error: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
maxlength: {
type: Number,
default: 1000,
}
});
const emits = defineEmits(['clearError']);
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div class="fullscreen-image-viewer fixed z-200 top-0 inset-0 flex justify-center align-center flex-col h-full bg-black">
<Swiper
:zoom="true"
:navigation="true"
:pagination="{
type: 'fraction',
}"
:initialSlide="activeIndex"
:modules="[Zoom, Navigation, Pagination]"
class="mySwiper w-full h-full"
@slider-move="vibrate"
>
<SwiperSlide v-for="image in images">
<div class="swiper-zoom-container">
<img :src="image.largeURL" :alt="image.alt" class="w-full h-full"/>
</div>
</SwiperSlide>
</Swiper>
<button
class="absolute z-50 text-white text-xl right-5 cursor-pointer"
style="top: calc(var(--tg-safe-area-inset-top, 5px) + var(--tg-content-safe-area-inset-top, 5px))"
@click="onClose"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<script setup>
import {Navigation, Pagination, Zoom} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
import {onMounted} from "vue";
const props = defineProps({
images: {
type: Array,
default: () => [],
},
activeIndex: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['close']);
let canVibrate = true;
function vibrate() {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
}, 50);
}
function onClose() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
emits('close');
}
</script>
<style>
.fullscreen-image-viewer .swiper-button-next,
.fullscreen-image-viewer .swiper-button-prev {
color: white;
mix-blend-mode: difference;
}
.fullscreen-image-viewer .swiper-pagination-fraction {
bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + var(--tg-content-safe-area-inset-bottom, px));
}
</style>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"/>
</svg>
</template>

View File

@@ -1,17 +0,0 @@
<template>
<div style="z-index: 99999" class="fixed left-0 w-full h-full bg-base-100 top-0">
<div class="flex flex-col items-center justify-center h-full">
<span class="loading loading-infinity loading-xl"></span>
<h1>{{ text }}</h1>
</div>
</div>
</template>
<script setup>
const props = defineProps({
text: {
type: String,
default: 'Получение данных...',
},
});
</script>

View File

@@ -1,56 +0,0 @@
<template>
<div class="telecart-navbar fixed navbar bg-primary text-primary-content z-50 shadow-md" :class="{'pb-0' : platform !== 'ios'}">
<div class="navbar-start">
<div v-if="false" class="dropdown">
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
</button>
</div>
</div>
<div class="navbar-center">
<RouterLink :to="{name: 'home'}" class="text-xl flex items-center">
<div class="avatar mr-2">
<div v-if="settings.app_icon" class="h-8 rounded-full bg-base-100">
<img :src="settings.app_icon" class="h-8" alt=""/>
</div>
</div>
{{ settings.app_name }}
</RouterLink>
</div>
<div class="navbar-end">
<button v-if="false" class="btn btn-ghost btn-circle">
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </svg>
<span class="badge badge-xs badge-secondary indicator-item">1</span>
</div>
</button>
</div>
</div>
</template>
<script setup>
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useMiniApp} from "vue-tg";
import {ref} from "vue";
const settings = useSettingsStore();
const emits = defineEmits(['drawer']);
const tg = useMiniApp();
const platform = ref();
platform.value = tg.platform;
function toggleDrawer() {
emits('drawer');
}
</script>
<style scoped>
.telecart-navbar {
padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
min-height: var(--tc-navbar-min-height);
}
</style>

View File

@@ -1,15 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">🛒</span>
<h2 class="text-xl font-semibold mb-2">Здесь нет товаров</h2>
<p class="text-sm mb-4">
Попробуйте изменить настройки фильтров
</p>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => router.back();
</script>

View File

@@ -1,13 +0,0 @@
<template>
<span>{{ formatPrice(value) }} </span>
</template>
<script setup>
import {formatPrice} from "@/helpers.js";
const props = defineProps({
value: {
default: 0,
},
});
</script>

View File

@@ -1,20 +0,0 @@
<template>
<div class="border-b mb-5 border-b-base-200 flex justify-between">
<div class="mb-2">Рекомендуемые товары</div>
<div>
<input
type="checkbox"
class="toggle"
v-model="filter.criteria.product_for_main_page.params.value"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>

View File

@@ -1,42 +0,0 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Категория</div>
<div v-if="categoriesStore.isLoading" class="skeleton h-10 w-full"></div>
<select
v-else
v-model.number="props.filter.criteria.product_category_id.params.value"
class="select w-full"
>
<option :value="null">Любая категория</option>
<SelectOption
v-for="category in categoriesStore.categories"
:key="category.id"
:level="0"
:category="category"
/>
</select>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {onMounted} from "vue";
import SelectOption from "@/components/ProductFilters/Components/ProductCategory/SelectOption.vue";
const props = defineProps({
filter: {
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const categoriesStore = useCategoriesStore();
onMounted(() => {
categoriesStore.fetchCategories();
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,31 +0,0 @@
<template>
<option :value="category.id">
{{ "-".repeat(level) }} {{ category.name }}
</option>
<SelectOption
v-if="category.children"
v-for="child in category.children"
:key="child.id"
:category="child"
:level="level + 1"
/>
</template>
<script setup>
const props = defineProps({
category: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
}
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,45 +0,0 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Цена</div>
<div class="flex justify-between">
<label class="input mr-3">
От
<input
type="number"
inputmode="numeric"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.from || '0'"
v-model="filter.criteria.product_price.params.value.from"
/>
</label>
<label class="input">
До
<input
type="number"
inputmode="numeric"
class="grow"
min="0"
step="50"
:placeholder="filter.criteria.product_price.params.value.to || '∞'"
:value="filter.criteria.product_price.params.value.to"
v-model="filter.criteria.product_price.params.value.to"
/>
</label>
</div>
</div>
</template>
<script setup>
const props = defineProps({
filter: {
required: true,
},
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,87 +0,0 @@
<template>
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
<swiper-slide
v-for="image in images"
:key="image.url"
class="bg-base-100 overflow-hidden"
style="aspect-ratio:1/1; border-radius:12px;"
>
<img
:src="image.url"
:alt="image.alt"
loading="lazy"
class="w-full h-full"
style="object-fit: contain"
/>
</swiper-slide>
</swiper-container>
</template>
<script setup>
import {onActivated, onMounted, onUnmounted, ref} from "vue";
const props = defineProps({
images: {
type: Array,
default: () => [],
},
});
const params = {
injectStyles: [`
.swiper-pagination {
position: relative;
padding-top: 15px;
}
`],
pagination: {
clickable: true,
},
}
const swiperEl = ref(null);
onUnmounted(() => {
});
onMounted(() => {
const el = swiperEl.value;
if (!el) return;
el.addEventListener('swiperactiveindexchange', () => {
window.Telegram?.WebApp?.HapticFeedback?.selectionChanged();
});
Object.assign(el, params);
el.initialize();
// 👇 важно, особенно если картинки подгружаются не сразу
el.addEventListener('swiperinit', () => {
el.swiper.update();
});
});
onActivated(() => {
const el = swiperEl.value
if (!el) return;
// Если swiper есть, но pagination потерялся — уничтожаем
if (el.swiper) {
try {
el.swiper.destroy(true, true)
} catch (e) {
console.warn('Failed to destroy swiper', e)
}
}
// Переинициализация с параметрами
Object.assign(el, params)
el.initialize()
// Пересчёт пагинации после инициализации
el.addEventListener('swiperinit', () => {
el.swiper.update()
})
})
</script>

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">😔</span>
<h2 class="text-xl font-semibold mb-2">Товар не найден</h2>
<p class="text-sm mb-4">К сожалению, запрошенный товар недоступен или был удалён.</p>
<button class="btn btn-primary" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Назад
</button>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goBack = () => router.back();
</script>

View File

@@ -1,18 +0,0 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -1,18 +0,0 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -1,18 +0,0 @@
<template>
<p>
<span class="text-xs font-medium">
{{ option.name }}: {{ option.value }} <span v-if="option.price"> ({{ option.price_prefix }}<Price :value="option.price"/>)</span>
</span>
</p>
</template>
<script setup>
import Price from "@/components/Price.vue";
const props = defineProps({
option: {
type: Object,
required: true,
},
});
</script>

View File

@@ -1,31 +0,0 @@
<template>
<div v-for="option in options" :key="option.product_option_id" class="mt-3">
<component
v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"
:is="componentMap[option.type]"
:modelValue="option"
/>
<div v-else class="text-sm text-error">
Тип опции "{{ option.type }}" не поддерживается.
</div>
</div>
</template>
<script setup>
import OptionRadio from "./Types/OptionRadio.vue";
import OptionCheckbox from "./Types/OptionCheckbox.vue";
import OptionText from "./Types/OptionText.vue";
import OptionTextarea from "./Types/OptionTextarea.vue";
import OptionSelect from "./Types/OptionSelect.vue";
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
const componentMap = {
radio: OptionRadio,
checkbox: OptionCheckbox,
text: OptionText,
textarea: OptionTextarea,
select: OptionSelect,
};
const options = defineModel();
</script>

View File

@@ -1,44 +0,0 @@
<template>
<div>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input
type="checkbox"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium group-has-checked:text-white">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</div>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(toggledValue) {
model.value.product_option_value.forEach(value => {
if (value === toggledValue) {
value.selected = !value.selected;
}
});
model.value.value = model.value.product_option_value.filter(item => item.selected === true);
emit('update:modelValue', model.value);
}
</script>

View File

@@ -1,42 +0,0 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.product_option_value"
class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input
type="radio"
:name="`option-${model.product_option_id}`"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(selectedValue) {
model.value.product_option_value.forEach(value => {
value.selected = (value === selectedValue);
});
model.value.value = selectedValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -1,38 +0,0 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<select
:name="`option-${model.product_option_id}`"
class="select"
@change="onChange"
>
<option value="" disabled selected>Выберите значение</option>
<option
v-for="value in model.product_option_value"
:key="value.product_option_value_id"
:value="value.product_option_value_id"
:selected="value.selected"
>
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</option>
</select>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function onChange(event) {
const selectedId = Number(event.target.value);
model.value.product_option_value.forEach(value => {
value.selected = (value.product_option_value_id === selectedId);
});
model.value.value = model.value.product_option_value.find(value => value.product_option_value_id === selectedId);
emit('update:modelValue', model.value);
}
</script>

View File

@@ -1,25 +0,0 @@
<template>
<div>
<h3 class="text-sm mb-2">
{{ name }} <span v-if="required" class="text-error">*</span>
</h3>
<fieldset>
<slot></slot>
</fieldset>
</div>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
}
});
</script>

View File

@@ -1,23 +0,0 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<input
type="text"
class="input"
:placeholder="model.name"
:value="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -1,23 +0,0 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<textarea
type="text"
class="textarea"
:placeholder="model.name"
v-text="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -1,140 +0,0 @@
<template>
<div class="mx-auto max-w-2xl px-4 py-4 pb-14">
<h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
<template v-if="products.length > 0">
<div
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
>
<RouterLink
v-for="(product, index) in products"
:key="product.id"
class="product-grid-card group"
:to="`/product/${product.id}`"
@click="productClick(product, index)"
>
<ProductImageSwiper :images="product.images"/>
<h3 class="product-title mt-4 text-sm">{{ product.name }}</h3>
<div v-if="product.special" class="mt-1">
<p class="text-xs line-through mr-2">{{ product.price }}</p>
<p class="text-lg font-medium">{{ product.special }}</p>
</div>
<p v-else class="mt-1 text-lg font-medium">{{ product.price }}</p>
</RouterLink>
<div ref="bottom" style="height: 1px;"></div>
</div>
<div v-if="isLoadingMore" class="text-center mt-5">
<span class="loading loading-spinner loading-md"></span> Загрузка товаров...
</div>
<div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
{{ settings.texts.no_more_products }}
</div>
</template>
<div v-else-if="isLoading === true"
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
<div class="aspect-square bg-gray-200 rounded-md"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
<NoProducts v-else/>
</div>
</template>
<script setup>
import NoProducts from "@/components/NoProducts.vue";
import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {ref} from "vue";
import {useIntersectionObserver} from '@vueuse/core';
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const bottom = ref(null);
const emits = defineEmits(['loadMore']);
const props = defineProps({
products: {
type: Array,
default: () => [],
},
hasMore: {
type: Boolean,
default: false,
},
categoryName: {
type: String,
default: () => '',
},
isLoading: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
}
});
function productClick(product, index) {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"click": {
"products": [
{
"id": product.id,
"name": product.name,
"price": product.final_price_numeric,
"brand": product.manufacturer_name,
"category": product.category_name,
"list": "Главная страница",
"position": index,
}
]
}
}
});
}
useIntersectionObserver(
bottom,
([entry]) => {
console.debug('Check Intersection');
if (entry?.isIntersecting === true
&& props.hasMore === true
&& props.isLoading === false
&& props.isLoadingMore === false
) {
emits('loadMore');
}
},
{
root: null,
rootMargin: '400px 0',
}
);
</script>
<style scoped>
.product-grid-card .product-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--product_list_title_max_lines, 3);
overflow: hidden;
}
</style>

View File

@@ -1,55 +0,0 @@
<template>
<div class="flex items-center text-center">
<button class="btn" :class="btnClassList" @click="dec" :disabled="disabled">-</button>
<div class="w-10 h-10 flex items-center justify-center font-bold">{{ model }}</div>
<button class="btn" :class="btnClassList" @click="inc" :disabled="disabled">+</button>
</div>
</template>
<script setup>
import {computed} from "vue";
const model = defineModel();
const props = defineProps({
max: Number,
size: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
}
});
const btnClassList = computed(() => {
let classList = ['btn'];
if (props.size) {
classList.push(`btn-${props.size}`);
}
return classList;
});
function inc() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
if (props.max && model.value + 1 > props.max) {
model.value = props.max;
return;
}
model.value++;
}
function dec() {
if (props.disabled) return;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
if (model.value - 1 >= 1) {
model.value--;
}
}
</script>

View File

@@ -1,39 +0,0 @@
<template>
<div class="search-wrapper w-full">
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
readonly
class="grow input-lg w-full"
placeholder="Поиск по магазину"
@click="showSearchPage"
/>
</label>
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {useSearchStore} from "@/stores/SearchStore.js";
const router = useRouter();
function showSearchPage() {
router.push({name: 'search'});
useSearchStore().reset();
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
}
</script>

View File

@@ -1,7 +0,0 @@
export const SUPPORTED_OPTION_TYPES = [
'checkbox',
'radio',
'select',
'text',
'textarea',
];

View File

@@ -1,15 +0,0 @@
export const YA_METRIKA_GOAL = {
ADD_TO_CART: 'add_to_cart',
PRODUCT_OPEN_EXTERNAL: 'product_open_external',
CREATE_ORDER: 'create_order',
ORDER_CREATED_SUCCESS: 'order_created_success',
VIEW_PRODUCT: 'view_product',
VIEW_CART: 'view_cart',
VIEW_CHECKOUT: 'view_checkout',
VIEW_HOME: 'view_home',
VIEW_FILTERS: 'view_filters',
FILTERS_APPLY: 'filters_apply',
FILTERS_RESET: 'filters_reset',
VIEW_SEARCH: 'view_search',
PERFORM_SEARCH: 'perform_search',
};

View File

@@ -1,30 +0,0 @@
export function isNotEmpty(value) {
if (value === null || value === undefined) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return Object.keys(value).length > 0;
if (typeof value === 'string') return value.trim() !== '';
return true; // для чисел, булевых и т.п.
}
export function formatPrice(raw) {
if (raw === null || raw === undefined) return '';
const str = String(raw).trim();
const match = str.match(/^([+-]?)(\d+(?:\.\d+)?)/);
if (!match) return '';
const sign = match[1] || '';
const num = parseFloat(match[2]);
if (isNaN(num) || num === 0) return '';
const formatted = Math.round(num)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${sign}${formatted}`;
}

View File

@@ -1,71 +0,0 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css'
import {VueTelegramPlugin} from 'vue-tg';
import {router} from './router';
import {createPinia} from 'pinia';
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import ApplicationError from "@/ApplicationError.vue";
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
import {injectYaMetrika} from "@/utils/yaMetrika.js";
import { register } from 'swiper/element/bundle';
import 'swiper/element/bundle';
import 'swiper/css/bundle';
import AppLoading from "@/AppLoading.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
register();
const pinia = createPinia();
const app = createApp(App);
app
.use(pinia)
.use(router)
.use(VueTelegramPlugin);
const settings = useSettingsStore();
const appLoading = createApp(AppLoading);
appLoading.mount('#app');
settings.load()
.then(() => window.Telegram.WebApp.lockOrientation())
.then(async () => {
console.debug('Load default filters for the main page');
const filtersStore = useProductFiltersStore();
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
})
.then(() => {
if (settings.app_enabled === false) {
throw new Error('App disabled (maintenance mode)');
}
})
.then(() => {
console.debug('[Init] Set theme attributes');
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
if (settings.night_auto) {
window.Telegram.WebApp.onEvent('themeChanged', function () {
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
});
}
for (const key in settings.theme.variables) {
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
}
})
.then(() => {
console.debug('[Init] Load front page categories and products.');
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
})
.then(() => new AppMetaInitializer(settings).init())
.then(() => { appLoading.unmount(); app.mount('#app'); })
.then(() => window.Telegram.WebApp.ready())
.then(() => settings.ya_metrika_enabled && injectYaMetrika())
.catch(error => {
console.error(error);
const errorApp = createApp(ApplicationError, {error});
errorApp.mount('#app-error');
});

View File

@@ -1,43 +0,0 @@
import {createRouter, createWebHashHistory} from 'vue-router';
import Home from './views/Home.vue';
import Product from './views/Product.vue';
import CategoriesList from "./views/CategoriesList.vue";
import Cart from "./views/Cart.vue";
import Products from "@/views/Products.vue";
import Checkout from "@/views/Checkout.vue";
import OrderCreated from "@/views/OrderCreated.vue";
import Search from "@/views/Search.vue";
import Filters from "@/views/Filters.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const routes = [
{
path: '/',
name: 'home',
component: Home,
},
{path: '/filters', name: 'filters', component: Filters},
{path: '/product/:id', name: 'product.show', component: Product},
{
path: '/products/:category_id',
name: 'product.categories.show',
component: Products,
},
{path: '/categories', name: 'categories', component: CategoriesList},
{path: '/category/:id', name: 'category.show', component: CategoriesList},
{path: '/cart', name: 'cart', component: Cart},
{path: '/checkout', name: 'checkout', component: Checkout},
{path: '/success', name: 'order_created', component: OrderCreated},
{path: '/search', name: 'search', component: Search},
];
export const router = createRouter({
history: createWebHashHistory('/image/catalog/tgshopspa/'),
routes,
});
router.beforeEach((to, from, next) => {
const ym = useYaMetrikaStore();
ym.prevPath = from.path;
next();
});

View File

@@ -1,165 +0,0 @@
import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {addToCart, cartEditItem, cartRemoveItem, getCart, setCoupon, setVoucher} from "@/utils/ftch.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
productsCount: 0,
total: 0,
isLoading: false,
reason: null,
error_warning: '',
attention: '',
success: '',
coupon: '',
voucher: '',
}),
getters: {
canCheckout: (state) => {
if (state.isLoading || state.error_warning.length > 0) {
return false;
}
},
},
actions: {
async getProducts() {
try {
this.isLoading = true;
const {data} = await getCart();
this.items = data.products;
this.productsCount = data.total_products_count;
this.totals = data.totals;
this.error_warning = data.error_warning;
this.attention = data.attention;
this.success = data.success;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async addProduct(productId, productName, price, quantity = 1, options = []) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append("product_id", productId);
formData.append("quantity", quantity);
// TODO: Add support different types of options
options.forEach((option) => {
if (option.type === "checkbox" && Array.isArray(option.value)) {
option.value.forEach(item => {
formData.append(`option[${option.product_option_id}][]`, item.product_option_value_id);
});
} else if (option.type === "radio" && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
} else if (option.type === "select" && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
} else if ((option.type === "text" || option.type === 'textarea') && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value);
}
})
const response = await addToCart(formData);
if (response.error) {
throw new Error(JSON.stringify(response.error));
}
await this.getProducts();
} catch (error) {
console.log(error);
throw error;
} finally {
this.isLoading = false;
}
},
async removeItem(cartItem, rowId, index = 0) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append('key', rowId);
await cartRemoveItem(formData);
useYaMetrikaStore().dataLayerPush({
"ecommerce": {
"currencyCode": useSettingsStore().currency_code,
"remove": {
"products": [
{
"id": cartItem.product_id,
"name": cartItem.name,
"quantity": cartItem.quantity,
"position": index
}
]
}
}
});
await this.getProducts();
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async setQuantity(cartId, quantity) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append(`quantity[${cartId}]`, quantity);
await cartEditItem(formData);
await this.getProducts();
} catch (error) {
console.log(error);
} finally {
this.isLoading = false;
}
},
async applyCoupon() {
try {
this.isLoading = true;
this.error_warning = '';
const response = await setCoupon(this.coupon);
if (response.error) {
this.error_warning = response.error;
} else {
await this.getProducts();
}
} catch (error) {
console.log(error);
this.error_warning = 'Возникла ошибка';
} finally {
this.isLoading = false;
}
},
async applyVoucher() {
try {
this.isLoading = true;
this.error_warning = '';
const response = await setVoucher(this.voucher);
if (response.error) {
this.error_warning = response.error;
} else {
await this.getProducts();
}
} catch (error) {
console.log(error);
this.error_warning = 'Возникла ошибка';
} finally {
this.isLoading = false;
}
},
},
});

View File

@@ -1,61 +0,0 @@
import {defineStore} from "pinia";
import ftch from "../utils/ftch.js";
export const useCategoriesStore = defineStore('categories', {
state: () => ({
topCategories: [],
categories: [],
isLoading: false,
isCategoriesLoaded: false,
}),
actions: {
async fetchCategories() {
if (this.isCategoriesLoaded === false && this.categories.length === 0) {
try {
this.isLoading = true;
const {data} = await ftch('categoriesList');
this.categories = data;
this.isCategoriesLoaded = true;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
}
},
async fetchTopCategories() {
try {
this.isLoading = true;
const response = await ftch('categoriesList', {
forMainPage: true,
});
this.topCategories = response.data;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async findCategoryById(id, list = []) {
if (! id) return null;
if (list && list.length === 0) {
await this.fetchCategories();
list = this.categories;
}
for (const cat of list) {
if (parseInt(cat.id) === parseInt(id)) return cat;
if (cat.children?.length) {
const found = await this.findCategoryById(id, cat.children);
if (found) return found;
}
}
return null;
}
},
});

View File

@@ -1,114 +0,0 @@
import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {storeOrder} from "@/utils/ftch.js";
import {useCartStore} from "@/stores/CartStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCheckoutStore = defineStore('checkout', {
state: () => ({
customer: {
firstName: "",
lastName: "",
email: "",
phone: "",
address: "",
comment: "",
tgData: null,
},
order: null,
isLoading: false,
validationErrors: {},
}),
getters: {
hasError: (state) => {
return (field) => isNotEmpty(state.validationErrors[field]);
},
},
actions: {
async makeOrder() {
try {
this.isLoading = true;
const data = window.Telegram.WebApp.initDataUnsafe;
console.log("Allows write to PM: ", data.user.allows_write_to_pm);
if (! data.user.allows_write_to_pm) {
console.log("Sending request");
const granted = await new Promise(resolve => {
window.Telegram.WebApp.requestWriteAccess((granted) => {
resolve(granted);
});
});
if (granted) {
data.user.allows_write_to_pm = true;
console.log('Пользователь разрешил отправку сообщений');
} else {
alert('Вы не дали разрешение — бот не сможет отправлять вам уведомления');
}
}
this.customer.tgData = data;
const response = await storeOrder(this.customer);
this.order = response.data;
if (! this.order.id) {
console.debug(response.data);
throw new Error('Ошибка создания заказа.');
}
const yaMetrika = useYaMetrikaStore();
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
price: this.order?.final_total_numeric,
currency: this.order?.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": useSettingsStore().currency_code,
"purchase": {
"actionField": {
"id": this.order.id,
'revenue': this.order?.final_total_numeric,
},
"products": this.order.products ? this.order.products.map((product, index) => {
return {
id: product.product_id,
name: product.name,
price: product.total_numeric,
position: index,
quantity: product.quantity,
};
}) : [],
}
}
});
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
await useCartStore().getProducts();
} catch (error) {
if (error.response?.status === 422) {
this.validationErrors = error.response._data.data;
} else {
console.error('Server error', error);
}
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
throw error;
} finally {
this.isLoading = false;
}
},
clearError(field) {
this.validationErrors[field] = null;
},
},
});

View File

@@ -1,41 +0,0 @@
import {defineStore} from "pinia";
import {getFiltersForMainPage} from "@/utils/ftch.js";
import {md5} from "js-md5";
export const useProductFiltersStore = defineStore('product_filters', {
state: () => ({
isLoading: false,
draft: {},
applied: {},
default: {},
fullPath: '',
}),
getters: {
paramsHashForRouter: (state) => md5(JSON.stringify({ filters: state.applied })),
isFiltersChanged: (state) =>
md5(JSON.stringify({ filters: state.applied })) !== md5(JSON.stringify({ filters: state.default })),
},
actions: {
async fetchFiltersForMainPage() {
if (this.isLoading) return;
try {
this.isLoading = true;
const response = await getFiltersForMainPage();
this.default = response.data;
return response.data;
} catch (error) {
console.log(error);
} finally {
this.isLoading = false;
}
},
clear() {
this.filters = {};
}
},
});

View File

@@ -1,122 +0,0 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
import {md5} from 'js-md5';
import {toRaw} from "vue";
export const useProductsStore = defineStore('products', {
state: () => ({
products: {
data: [],
meta: {
hasMore: true,
},
},
filters: null,
filtersFullUrl: '',
search: '',
page: 1,
isLoading: false,
isLoadingMore: false,
loadFinished: false,
savedScrollY: 0,
currentLoadedParamsHash: null,
}),
getters: {
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
paramsHashForRouter: (state) => md5(JSON.stringify({
search: state.search,
filters: toRaw(state.filters),
})),
},
actions: {
getParams() {
return {
page: this.page,
search: this.search,
filters: toRaw(this.filters),
};
},
async fetchProducts() {
try {
console.debug('Current params hash: ', this.currentLoadedParamsHash);
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
console.debug('Loading products from cache');
return new Promise((resolve, reject) => {
resolve(this.products);
});
}
console.debug('Requested param cache: ', this.paramsHash);
console.debug('Invalidate cache. Fetch products from server.', this.getParams());
const response = await ftch('products', null, this.getParams());
this.currentLoadedParamsHash = this.paramsHash;
console.debug('Products loaded from server.');
console.debug('New params hash: ', this.currentLoadedParamsHash);
return {
meta: response.meta,
data: response.data,
};
} catch (error) {
console.error("Failed to load products");
console.error(error);
} finally {
}
},
async loadProducts(filters = null) {
if (this.isLoading) return;
try {
console.debug('Load products with filters', filters);
this.reset();
this.isLoading = true;
this.page = 1;
this.loadFinished = false;
this.search = '';
this.filters = filters;
this.products = await this.fetchProducts();
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoading = false;
this.loadFinished = true;
}
},
async loadMore() {
if (this.isLoading || this.isLoadingMore || this.products.meta.hasMore === false) return;
try {
this.isLoadingMore = true;
this.page++;
console.debug('Load more products for page: ', this.page);
const response = await this.fetchProducts();
this.products.meta = response.meta;
this.products.data.push(...response.data);
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoadingMore = false;
this.loadFinished = true;
this.isLoading = false;
}
},
reset() {
this.isLoading = false;
this.page = 1;
this.loadFinished = false;
this.search = '';
this.products = {
data: [],
meta: {
hasMore: true,
},
};
},
},
});

View File

@@ -1,56 +0,0 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
export const useSearchStore = defineStore('search', {
state: () => ({
search: '',
page: 1,
products: {
data: [],
meta: {},
},
isLoading: false,
isSearchPerformed: false,
}),
actions: {
reset() {
this.search = '';
this.isSearchPerformed = false;
this.isLoading = false;
this.page = 1;
this.products = {
data: [],
meta: {},
};
},
async performSearch() {
if (!this.search) {
return this.reset();
}
useYaMetrikaStore().reachGoal(YA_METRIKA_GOAL.PERFORM_SEARCH, {
keyword: this.search,
});
try {
this.isLoading = true;
this.products = await ftch('products', {
page: this.page,
perPage: 10,
search: this.search,
});
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
this.isSearchPerformed = true;
}
},
},
});

View File

@@ -1,56 +0,0 @@
import {defineStore} from "pinia";
import {fetchSettings} from "@/utils/ftch.js";
export const useSettingsStore = defineStore('settings', {
state: () => ({
app_enabled: true,
app_debug: false,
store_enabled: true,
app_name: 'OpenCart Telegram магазин',
app_icon: '',
app_icon192: '',
app_icon180: '',
app_icon152: '',
app_icon120: '',
manifest_url: null,
night_auto: true,
ya_metrika_enabled: false,
feature_coupons: false,
feature_vouchers: false,
currency_code: null,
theme: {
light: 'light', dark: 'dark', variables: {
'--product_list_title_max_lines': 2,
}
},
texts: {
no_more_products: 'Нет товаров',
empty_cart: 'Корзина пуста',
order_created_success: 'Заказ успешно оформлен.',
},
}),
actions: {
async load() {
console.log('Load settings');
const settings = await fetchSettings();
this.manifest_url = settings.manifest_url;
this.app_name = settings.app_name;
this.app_icon = settings.app_icon;
this.app_icon192 = settings.app_icon192;
this.app_icon180 = settings.app_icon180;
this.app_icon152 = settings.app_icon152;
this.app_icon120 = settings.app_icon120;
this.theme.light = settings.theme_light;
this.theme.dark = settings.theme_dark;
this.ya_metrika_enabled = settings.ya_metrika_enabled;
this.app_enabled = settings.app_enabled;
this.app_debug = settings.app_debug;
this.store_enabled = settings.store_enabled;
this.feature_coupons = settings.feature_coupons;
this.feature_vouchers = settings.feature_vouchers;
this.currency_code = settings.currency_code;
this.texts = settings.texts;
}
}
});

View File

@@ -1,130 +0,0 @@
import {defineStore} from "pinia";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import sha256 from 'crypto-js/sha256';
import {toRaw} from "vue";
export const useYaMetrikaStore = defineStore('ya_metrika', {
state: () => ({
queue: [],
prevPath: null,
}),
actions: {
pushHit(url, params = {}) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
const fullUrl = `/#${url}`;
params.referer = params.referer ?? this.prevPath;
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] Hit ', fullUrl);
console.debug('[ym] ID ', window.YA_METRIKA_ID);
console.debug('[ym] params ', params);
window.ym(window.YA_METRIKA_ID, 'hit', fullUrl, params);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
this.queue.push({
event: 'hit',
payload: {
fullUrl,
params,
}
});
}
},
reachGoal(target, params = {}) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] reachGoal ', target, ' params: ', params);
window.ym(window.YA_METRIKA_ID, 'reachGoal', target, params);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
this.queue.push({
event: 'reachGoal',
payload: {
target,
params
},
});
}
},
initUserParams() {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
let tgID = null;
if (window?.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
tgID = sha256(window.Telegram.WebApp.initDataUnsafe.user.id).toString();
}
const userParams = {
tg_id: tgID,
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
platform: window.Telegram?.WebApp?.platform || 'unknown',
};
window.ym(window.YA_METRIKA_ID, 'userParams', userParams);
console.debug('[ym] User params initialized: ', userParams);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Could not init user params.');
}
},
processQueue() {
if (this.queue.length === 0) {
return;
}
console.debug('[ym] Start processing queue. Size: ', this.queue.length);
while (this.queue.length > 0) {
const item = this.queue.shift();
if (item.event === 'hit') {
console.debug('[ym] Queue ', toRaw(item));
window.ym(window.YA_METRIKA_ID, 'hit', item.payload.fullUrl, item.payload.params);
} else if (item.event === 'reachGoal') {
window.ym(window.YA_METRIKA_ID, 'reachGoal', item.payload.target, item.payload.params);
} else if (item.event === 'dataLayer') {
console.debug('[ym] queue dataLayer push: ', item.payload);
window.dataLayer.push(item.payload);
} else {
console.error('[ym] Unsupported queue event: ', item.event);
}
}
console.debug('[ym] Queue processing complete. Size: ', this.queue.length);
},
dataLayerPush(object) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (Array.isArray(window.dataLayer)) {
console.debug('[ym] dataLayer push: ', object);
window.dataLayer.push(object);
} else {
console.debug('[ym] dataLayer inaccessible. Put to queue');
this.queue.push({
event: 'dataLayer',
payload: object,
});
}
}
},
});

View File

@@ -1,70 +0,0 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: all;
}
html, body, #app {
width: 100%;
height: 100%;
}
html {
--swiper-pagination-color: var(--color-primary);
--swiper-navigation-color: var(--color-primary);
--swiper-pagination-bullet-inactive-color: var(--color-base-content);
--swiper-pagination-fraction-color: var(--color-neutral-content);
--product_list_title_max_lines: 1;
--tc-navbar-min-height: 3rem;
}
.swiper-pagination-bullets {
border-radius: var(--radius-selector);
padding: 5px 0;
}
#app {
position: relative;
/*padding-top: var(--tg-content-safe-area-inset-top);*/
padding-bottom: var(--tg-content-safe-area-inset-bottom);
padding-left: var(--tg-content-safe-area-inset-left);
padding-right: var(--tg-content-safe-area-inset-right);
}
.app-container {
/*padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));*/
padding-bottom: var(--tg-safe-area-inset-bottom, 0px);
padding-left: var(--tg-safe-area-inset-left, 0px);
padding-right: var(--tg-safe-area-inset-right, 0px);
}
.safe-top {
padding-top: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
}
.app-header {
z-index: 60;
position: fixed;
background: var(--color-primary);
height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
min-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
max-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
display: flex;
flex-direction: column;
justify-content: end;
align-items: center;
color: white;
padding-bottom: 8px;
}
.telecart-main-section {
padding-top: calc(var(--tg-content-safe-area-inset-top, 0rem) + var(--tg-safe-area-inset-top, 0rem) + var(--tc-navbar-min-height));
}
.swiper-pagination-bullets > .swiper-pagination-bullet {
background-color: red;
color: red;
}
.swiper-pagination-bullets {
top: 10px;
}

View File

@@ -1,3 +0,0 @@
export default {
RULE_PRODUCT_PRICE: 'Цена',
};

View File

@@ -1,47 +0,0 @@
class AppMetaInitializer {
private readonly settings: object;
constructor(settings: object) {
this.settings = settings;
}
public init() {
console.log('Init app meta');
document.title = this.settings.app_name;
this.setMeta('application-name', this.settings.app_name);
this.setMeta('apple-mobile-web-app-title', this.settings.app_name);
this.setMeta('mobile-web-app-capable', 'yes');
this.setMeta('apple-mobile-web-app-capable', 'yes');
this.setMeta('apple-mobile-web-app-status-bar-style', 'default');
this.setMeta('theme-color', '#000000');
this.setMeta('msapplication-navbutton-color', '#000000');
this.setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent');
this.addLink('manifest', this.settings.manifest_url);
this.addLink('icon', this.settings.app_icon192, '192x192');
this.addLink('apple-touch-icon', this.settings.app_icon192);
this.addLink('apple-touch-icon', this.settings.app_icon180, '180x180');
this.addLink('apple-touch-icon', this.settings.app_icon152, '152x152');
this.addLink('apple-touch-icon', this.settings.app_icon120, '120x120');
}
private setMeta(name: string, content: string) {
let meta = document.querySelector(`meta[name="${name}"]`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', name);
document.head.appendChild(meta);
}
meta.setAttribute('content', content);
}
private addLink(rel: string, href: string, sizes?: string) {
const link = document.createElement('link');
link.rel = rel;
link.href = href;
if (sizes) link.sizes = sizes;
document.head.appendChild(link);
}
}
export default AppMetaInitializer;

View File

@@ -1,99 +0,0 @@
import {ofetch} from "ofetch";
const BASE_URL = '/';
function encodeBase64Unicode(str) {
return btoa(new TextEncoder().encode(str).reduce((data, byte) => data + String.fromCharCode(byte), ''));
}
export const apiFetch = ofetch.create({
throwHttpErrors: true,
onRequest({request, options}) {
const data = window.Telegram?.WebApp?.initData;
if (data) {
const encoded = encodeBase64Unicode(data);
options.headers = {
...options.headers,
'X-Telegram-InitData': encoded,
}
}
},
});
async function ftch(action, query = null, json = null) {
const options = {
method: json ? 'POST' : 'GET',
}
if (query) options.query = query;
if (json) options.body = json;
return await apiFetch(`${BASE_URL}index.php?route=extension/tgshop/handle&api_action=${action}`, options);
}
export async function storeOrder(data) {
return await apiFetch(`${BASE_URL}index.php?route=extension/tgshop/handle&api_action=storeOrder`, {
method: 'POST',
body: data,
});
}
export async function getCart() {
return await ftch('getCart');
}
export async function addToCart(data) {
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/add`, {
method: 'POST',
body: data,
});
}
export async function cartRemoveItem(data) {
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/remove`, {
method: 'POST',
body: data,
});
}
export async function cartEditItem(data) {
return await apiFetch(`${BASE_URL}index.php?route=checkout/cart/edit`, {
redirect: 'manual',
method: 'POST',
body: data,
});
}
export async function fetchSettings() {
return await ftch('settings');
}
export async function getFiltersForMainPage() {
return await ftch('filtersForMainPage');
}
export async function setCoupon(coupon) {
const formData = new FormData();
formData.append('coupon', coupon);
return await apiFetch(`${BASE_URL}index.php?route=extension/total/coupon/coupon`, {
method: 'POST',
body: formData,
});
}
export async function setVoucher(voucher) {
const formData = new FormData();
formData.append('voucher', voucher);
return await apiFetch(`${BASE_URL}index.php?route=extension/total/voucher/voucher`, {
method: 'POST',
body: formData,
});
}
export async function fetchBanner() {
return await ftch('banner');
}
export default ftch;

View File

@@ -1,30 +0,0 @@
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
function getMetrikaId() {
// Пробуем найти все элементы <script> с mc.yandex.ru
const scripts = Array.from(document.scripts);
for (const s of scripts) {
if (s.src.includes('mc.yandex.ru/metrika/tag.js')) {
const match = s.src.match(/id=(\d+)/);
if (match) return match[1];
}
}
return null;
}
export function injectYaMetrika() {
const script = document.createElement('script');
script.src = '/index.php?route=extension/tgshop/handle/ya_metrika';
// script.async = true;
document.head.appendChild(script);
console.debug('[Init] Yandex Metrika injected to the page.');
script.onload = () => {
window.YA_METRIKA_ID = getMetrikaId();
console.debug('[Init] Detected Yandex.Metrika ID:', window.YA_METRIKA_ID);
const yaMetrika = useYaMetrikaStore();
yaMetrika.initUserParams();
window.dataLayer = window.dataLayer || [];
yaMetrika.processQueue();
}
}

View File

@@ -1,187 +0,0 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-40">
<h2 class="text-2xl text-center">
Корзина
<span v-if="cart.isLoading" class="loading loading-spinner loading-md"></span>
</h2>
<div v-if="cart.attention" role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{{ cart.attention }}</span>
</div>
<div v-if="cart.error_warning" role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ cart.error_warning }}</span>
</div>
<div v-if="cart.items.length > 0">
<div
v-for="(item, index) in cart.items"
:key="item.cart_id"
class="card card-border bg-base-100 card-sm mb-3"
:class="item.stock === false ? 'border-error' : ''"
>
<div class="card-body">
<div class="flex">
<div class="avatar mr-5">
<div class="w-16 rounded">
<img :src="item.thumb"/>
</div>
</div>
<div>
<RouterLink :to="{name: 'product.show', params: {id: item.product_id}}" class="card-title">
{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span>
</RouterLink>
<p class="text-sm font-bold">{{ item.total }}</p>
<p>{{ item.price }}/ед</p>
<div>
<div v-for="option in item.option">
<p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p>
<!-- <component-->
<!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"-->
<!-- :is="componentMap[option.type]"-->
<!-- :option="option"-->
<!-- />-->
<!-- <div v-else class="text-sm text-error">-->
<!-- Тип опции "{{ option.type }}" не поддерживается.-->
<!-- </div>-->
</div>
</div>
</div>
</div>
<div class="card-actions justify-between">
<Quantity
:disabled="cart.isLoading"
v-model="item.quantity"
@update:modelValue="cart.setQuantity(item.cart_id, $event)"
/>
<button class="btn btn-error" @click="removeItem(item, item.cart_id, index)" :disabled="cart.isLoading">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
</div>
</div>
<div class="card card-border bg-base-100 mb-3">
<div class="card-body">
<h2 class="card-title">Ваша корзина</h2>
<div v-for="total in cart.totals">
<div class="flex justify-between">
<span class="text-xs text-base-content mr-2">{{ total.title }}:</span>
<span v-if="cart.isLoading" class="loading loading-spinner loading-xs"></span>
<span v-else class="text-xs font-bold">{{ total.text }}</span>
</div>
</div>
</div>
</div>
<div
v-if="settings.feature_coupons || settings.feature_vouchers"
class="card card-border bg-base-100 mb-3"
>
<div class="card-body">
<div v-if="settings.feature_coupons" class="join">
<input v-model="cart.coupon" type="text" class="input" placeholder="Промокод"/>
<button
class="btn"
:disabled="!cart.coupon"
@click="cart.applyCoupon"
>Применить</button>
</div>
<div v-if="settings.feature_vouchers" class="join">
<input v-model="cart.voucher" type="text" class="input" placeholder="Подарочный сертификат"/>
<button
class="btn"
:disabled="!cart.voucher"
@click="cart.applyVoucher"
>Применить</button>
</div>
</div>
</div>
<div class="btn-checkout fixed px-4 pt-4 left-0 w-full z-50 flex justify-end items-center gap-2">
<button
class="btn btn-primary select-none shadow-xl"
:disabled="cart.canCheckout === false"
@click="goToCheckout"
>
Перейти к оформлению
</button>
</div>
</div>
<div
v-else
class="text-center rounded-2xl"
>
<div class="text-5xl mb-4">🛒</div>
<p class="text-lg mb-3">{{ settings.texts.empty_cart }}</p>
<RouterLink class="btn btn-primary" to="/">Начать покупки</RouterLink>
</div>
</div>
</template>
<script setup>
import { useCartStore } from '../stores/CartStore.js'
import Quantity from "@/components/Quantity.vue";
// import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
import OptionRadio from "@/components/ProductOptions/Cart/OptionRadio.vue";
import OptionCheckbox from "@/components/ProductOptions/Cart/OptionCheckbox.vue";
import OptionText from "@/components/ProductOptions/Cart/OptionText.vue";
import {computed, onMounted} from "vue";
import {useRoute, useRouter} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const yaMetrika = useYaMetrikaStore();
const cart = useCartStore();
const router = useRouter();
const settings = useSettingsStore();
// const componentMap = {
// radio: OptionRadio,
// select: OptionRadio,
// checkbox: OptionCheckbox,
// text: OptionText,
// textarea: OptionText,
// };
const lastTotal = computed(() => {
return cart.totals.at(-1) ?? null;
});
function removeItem(cartItem, cartId, index) {
cart.removeItem(cartItem, cartId, index);
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
}
function goToCheckout() {
router.push({name: 'checkout'});
}
onMounted(async () => {
window.document.title = 'Корзина покупок';
yaMetrika.pushHit(route.path, {
title: 'Корзина покупок',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CART);
});
</script>
<style scoped>
.btn-checkout {
bottom: calc(var(--spacing, 0px) * 22 + var(--tg-safe-area-inset-bottom, 0px))
}
</style>

View File

@@ -1,137 +0,0 @@
<template>
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8 mb-5">
<h2 class="text-3xl mb-5">Категории</h2>
<div v-if="categoriesStore.isLoading" class="flex flex-col gap-4">
<div class="skeleton h-14 w-full"></div>
<div class="skeleton h-14 w-full"></div>
<div class="skeleton h-14 w-full"></div>
<div class="skeleton h-14 w-full"></div>
<div class="skeleton h-14 w-full"></div>
</div>
<template v-else>
<button v-if="parentId && parentCategory" class="py-1 px-4 flex items-center mb-3 cursor-pointer" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 min-w-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
<span class="ml-2 line-clamp-2">Назад к "{{ parentCategory.name }}"</span>
</button>
<button
v-if="false"
class="py-2 px-4 flex items-center mb-3 cursor-pointer border-b w-full pb-2 border-base-200"
@click.prevent="showProductsInParentCategory"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
<span class="ml-2">Показать товары из "{{ parentCategory.name }}"</span>
</button>
<TransitionGroup
name="stagger"
tag="ul"
appear
>
<li
v-for="(category, i) in categories"
:key="category.id"
:style="{ '--i': i }"
>
<CategoryItem
:category="category"
@onSelect="onSelect"
class="block px-1 rounded-xl transition hover:bg-base-100/60 active:scale-[0.98] will-change-transform"
/>
</li>
</TransitionGroup>
</template>
</div>
</template>
<script setup>
import {computed, onMounted} from "vue";
import {router} from "@/router.js";
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useRoute} from "vue-router";
import CategoryItem from "@/components/CategoriesList/CategoryItem.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const route = useRoute();
const categoriesStore = useCategoriesStore();
const yaMetrika = useYaMetrikaStore();
const parentId = computed(() => route.params.id ? Number(route.params.id) : null);
// 🔧 Рекурсивный поиск по всему дереву
function findCategoryById(id, list = categoriesStore.categories) {
if (id == null) return null;
for (const cat of list) {
if (cat.id === id) return cat;
if (cat.children?.length) {
const found = findCategoryById(id, cat.children);
if (found) return found;
}
}
return null;
}
const parentCategory = computed(() => findCategoryById(parentId.value));
// Если мы в корне — показываем корневые категории,
// если внутри — показываем детей найденной категории (или пустой массив, если не нашли)
const categories = computed(() => {
if (!parentId.value) return categoriesStore.categories;
return parentCategory.value?.children ?? [];
});
function onSelect(category) {
if (!category?.children?.length) {
router.push({name: "product.categories.show", params: {category_id: category.id}});
} else {
router.push({name: "category.show", params: {id: category.id}});
}
}
function goBack() {
router.back();
}
function showProductsInParentCategory() {
if (parentId.value != null) {
router.push({name: "product.categories.show", params: {category_id: parentId.value}});
}
}
onMounted(async () => {
window.document.title = 'Каталог';
yaMetrika.pushHit(route.path, {
title: 'Каталог',
});
await categoriesStore.fetchCategories();
});
</script>
<style scoped>
/* Стаггер для элементов списка */
.stagger-enter-from,
.stagger-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
.stagger-enter-active {
transition: opacity .28s ease, transform .28s cubic-bezier(.22,.61,.36,1);
transition-delay: calc(var(--i) * 40ms); /* задержка по индексу */
}
.stagger-leave-active {
position: absolute; /* чтобы соседей не дёргало */
width: 100%;
transition: opacity .18s ease, transform .18s ease;
}
.stagger-move {
transition: transform .28s ease; /* анимация перестановки элементов */
}
</style>

View File

@@ -1,112 +0,0 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-20">
<h2 class="text-2xl mb-5 text-center">
Оформление заказа
</h2>
<div class="w-full">
<TgInput
v-model="checkout.customer.firstName"
placeholder="Введите имя"
:error="checkout.validationErrors.firstName"
:maxlength="32"
@clearError="checkout.clearError('firstName')"
/>
<TgInput
v-model="checkout.customer.lastName"
placeholder="Введите фамилию"
:maxlength="32"
:error="checkout.validationErrors.lastName"
@clearError="checkout.clearError('lastName')"
/>
<fieldset class="fieldset">
<IMaskComponent
v-model="checkout.customer.phone"
type="tel"
class="input input-lg w-full"
mask="+{7} (000) 000-00-00"
placeholder="Введите телефон"
:unmask="true"
/>
<p v-if="error" class="label text-error">{{ checkout.validationErrors.phone }}</p>
</fieldset>
<TgInput
v-model="checkout.customer.email"
type="email"
placeholder="Введите email (опционально)"
:maxlength="96"
:error="checkout.validationErrors.email"
@clearError="checkout.clearError('email')"
/>
<TgTextarea
v-model="checkout.customer.comment"
placeholder="Комментарий (опционально)"
:error="checkout.validationErrors.comment"
@clearError="checkout.clearError('comment')"
/>
</div>
<div
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
<div v-if="error" class="text-error text-sm">{{ error }}</div>
<button
:disabled="checkout.isLoading"
class="btn btn-primary w-full"
@click="onCreateBtnClick"
>
<span v-if="checkout.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }}
</button>
</div>
</div>
</template>
<script setup>
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import TgInput from "@/components/Form/TgInput.vue";
import TgTextarea from "@/components/Form/TgTextarea.vue";
import {useRoute, useRouter} from "vue-router";
import {computed, onMounted, ref} from "vue";
import {IMaskComponent} from "vue-imask";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
const route = useRoute();
const router = useRouter();
const error = ref(null);
const btnText = computed(() => {
return checkout.isLoading ? 'Подождите...' : 'Создать заказ';
});
async function onCreateBtnClick() {
try {
error.value = null;
yaMetrika.reachGoal(YA_METRIKA_GOAL.CREATE_ORDER, {
price: checkout.order?.final_total_numeric,
currency: checkout.order?.currency,
});
await checkout.makeOrder();
router.push({name: 'order_created'});
} catch {
error.value = 'Невозможно создать заказ.';
}
}
onMounted(async () => {
window.document.title = 'Оформление заказа';
yaMetrika.pushHit(route.path, {
title: 'Оформление заказа',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_CHECKOUT);
});
</script>

View File

@@ -1,109 +0,0 @@
<template>
<div ref="goodsRef" class="pb-10">
<div class="flex flex-col">
<header class="text-center shrink-0 p-3 font-bold text-xl">
Фильтры
</header>
<main class="mt-5 px-5 pt-5 bg-base-200">
<div
v-if="filtersStore.draft?.rules && Object.keys(filtersStore.draft.rules).length > 0"
v-for="(filter, filterId) in filtersStore.draft.rules"
>
<component
v-if="componentMap[filterId]"
:is="componentMap[filterId]"
:filter="filter"
/>
<p v-else>Not supported: {{ filterId }}</p>
</div>
<div v-else>
Нет фильтров
</div>
</main>
</div>
<div
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
<button
class="btn btn-link w-full"
@click="resetFilters"
>
Сбросить фильтры
</button>
<button
class="btn btn-primary w-full"
@click="applyFilters"
>
Применить
</button>
</div>
</div>
</template>
<script setup>
import {nextTick, onMounted} from "vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
import {useRoute, useRouter} from "vue-router";
import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
defineOptions({
name: 'Filters'
});
const componentMap = {
RULE_PRODUCT_PRICE: ProductPrice,
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
RULE_PRODUCT_CATEGORY: ProductCategory,
};
const router = useRouter();
const yaMetrika = useYaMetrikaStore();
const route = useRoute();
const emit = defineEmits(['close', 'apply']);
const filtersStore = useProductFiltersStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
const applyFilters = async () => {
filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft));
console.debug('Filters: apply filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.impactOccurred('soft');
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_APPLY);
await nextTick();
router.back();
}
const resetFilters = async () => {
filtersStore.applied = filtersStore.default;
console.debug('Filters: reset filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.notificationOccurred('success');
yaMetrika.reachGoal(YA_METRIKA_GOAL.FILTERS_RESET);
await nextTick();
window.scrollTo(0, 0);
router.back();
}
onMounted(async () => {
console.debug('Filters: OnMounted');
window.document.title = 'Фильтры';
yaMetrika.pushHit(route.path, {title: 'Фильтры'});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_FILTERS);
if (filtersStore.applied?.rules) {
console.debug('Filters: Found applied filters.');
filtersStore.draft = JSON.parse(JSON.stringify(filtersStore.applied));
} else {
console.debug('No filters. Load filters from server');
filtersStore.draft = await filtersStore.fetchFiltersForMainPage();
}
});
</script>

View File

@@ -1,152 +0,0 @@
<template>
<div ref="goodsRef" class="pb-20">
<CategoriesInline/>
<Banner/>
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: calc(var(--tg-safe-area-inset-bottom, 0px) + 80px);">
<div class="flex justify-center">
<button
@click="showFilters"
class="btn shadow-xl relative"
:class="{'btn-accent' : filtersStore.isFiltersChanged}"
>
<IconFunnel/>
Фильтры
<span v-if="filtersStore.isFiltersChanged" class="status status-primary"></span>
</button>
</div>
</div>
<ProductsList
:products="products"
:hasMore="hasMore"
:isLoading="isLoading"
:isLoadingMore="isLoadingMore"
@loadMore="onLoadMore"
/>
</div>
</template>
<script setup>
import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue";
import {nextTick, onActivated, onMounted, ref, toRaw} from "vue";
import IconFunnel from "@/components/Icons/IconFunnel.vue";
import {useRouter} from "vue-router";
import ftch from "@/utils/ftch.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import Banner from "@/components/Banner.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
defineOptions({
name: 'Home'
});
const router = useRouter();
const filtersStore = useProductFiltersStore();
const yaMetrika = useYaMetrikaStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
const settings = useSettingsStore();
const products = ref([]);
const hasMore = ref(false);
const isLoading = ref(false);
const isLoadingMore = ref(false);
const page = ref(1);
const perPage = 20;
function showFilters() {
haptic.impactOccurred('soft');
router.push({name: 'filters'});
}
async function fetchProducts() {
try {
isLoading.value = true;
console.debug('Home: Load products for Main Page.');
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
perPage: perPage,
filters: filtersStore.applied,
}));
products.value = response.data;
hasMore.value = response.meta.hasMore;
console.debug('Home: Products for main page loaded.');
yaMetrika.dataLayerPush({
ecommerce: {
currencyCode: settings.currency_code,
impressions: products.value.map((product, index) => {
return {
id: product.id,
name: product.name,
price: product.final_price_numeric,
brand: product.manufacturer_name,
category: product.category_name,
list: 'Главная страница',
position: index,
discount: product.price_numeric - product.final_price_numeric,
quantity: product.product_quantity,
};
}),
},
});
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
}
async function onLoadMore() {
try {
console.debug('Home: onLoadMore');
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
isLoadingMore.value = true;
page.value++;
console.debug('Home: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
filters: filtersStore.applied,
}));
products.value.push(...response.data);
hasMore.value = response.meta.hasMore;
} catch (error) {
console.error(error);
} finally {
isLoadingMore.value = false;
}
}
onActivated(() => {
yaMetrika.pushHit('/', {
title: 'Главная страница',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_HOME);
});
onMounted(async () => {
window.document.title = 'Главная страница';
console.debug("[Home] Home Mounted");
console.debug("[Home] Scroll top");
await fetchProducts();
window.scrollTo(0, 0);
});
</script>
<style scoped>
.filters-status {
background-color: var(--color-info);
color: var(--color-info);
box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);
aspect-ratio: 1;
border-radius: var(--radius-selector);
width: .5rem;
height: .5rem;
display: inline-block;
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-30 flex flex-col items-center h-full justify-center">
<div class="flex flex-col justify-center items-center px-5">
<div class="mb-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-25 text-success">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
</div>
<p class="text-2xl font-bold mb-3">Спасибо за заказ!</p>
<p class="text-center mb-4">{{ settings.texts.order_created_success }}</p>
<ul v-if="checkout.order" class="list w-full bg-base-200 mb-4">
<li class="list-row flex justify-between">
<div>Номер заказа:</div>
<div class="font-bold">#{{ checkout.order.id }}</div>
</li>
<li class="list-row flex justify-between">
<div>Дата:</div>
<div class="font-bold">{{ checkout.order.created_at }}</div>
</li>
<li class="list-row flex justify-between">
<div>Сумма:</div>
<div class="font-bold">{{ checkout.order.total }}</div>
</li>
</ul>
<p class="text-xs mb-10">
Подтверждение отправлено Вам в личных сообщениях.
</p>
<RouterLink class="btn btn-primary" to="/">На главную</RouterLink>
</div>
</div>
</template>
<script setup>
import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import {onMounted} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useRoute} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
const checkout = useCheckoutStore();
const yaMetrika = useYaMetrikaStore();
const settings = useSettingsStore();
const route = useRoute();
onMounted(() => {
window.document.title = 'Заказ оформлен';
yaMetrika.pushHit(route.path, {
title: 'Заказ оформлен',
});
});
</script>

View File

@@ -1,362 +0,0 @@
<template>
<div>
<div>
<swiper-container ref="swiperEl" init="false">
<swiper-slide
v-for="(image, index) in product.images"
lazy="true"
>
<img
:src="image.thumbnailURL"
:alt="image.alt"
@click="showFullScreen(index)"
/>
</swiper-slide>
</swiper-container>
<FullScreenImageViewer
v-if="isFullScreen"
:images="product.images"
:activeIndex="initialFullScreenIndex"
@close="closeFullScreen"
/>
<!-- Product info -->
<div class="mx-auto max-w-2xl px-4 pt-3 pb-32 sm:px-6 rounded-t-lg">
<div class="lg:col-span-2 lg:border-r lg:pr-8">
<h1 class="font-bold tracking-tight text-3xl">{{ product.name }}</h1>
</div>
<div>
<h3 class="text-sm font-medium">{{ product.manufacturer }}</h3>
</div>
<div class="mt-4 lg:row-span-3 lg:mt-0">
<div v-if="product.special" class="flex items-center">
<p class="text-2xl tracking-tight mr-3">{{ product.special }}</p>
<p class="text-base-400 line-through">{{ product.price }}</p>
</div>
<p v-else class="text-3xl tracking-tight">{{ product.price }}</p>
<p v-if="product.tax" class="text-sm">Без НДС: {{ product.tax }}</p>
<p v-if="product.points && product.points > 0" class="text-sm">Бонусные баллы: {{ product.points }}</p>
<p v-for="discount in product.discounts" class="text-sm">
{{ discount.quantity }} или больше {{ discount.price }}
</p>
<p v-if="false" class="text-xs">Кол-во на складе: {{ product.quantity }} шт.</p>
<p v-if="product.minimum && product.minimum > 1" class="text-xs">
Минимальное кол-во для заказа: {{ product.minimum }}
</p>
<p class="text-xs">Наличие: {{ product.stock }}</p>
</div>
<div v-if="product.options && product.options.length" class="mt-4">
<ProductOptions v-model="product.options"/>
</div>
<div class="py-10">
<!-- Description and details -->
<div>
<h3 class="sr-only">Description</h3>
<div class="space-y-6">
<p class="text-base" v-html="product.description"></p>
</div>
</div>
<div v-if="product.attribute_groups && product.attribute_groups.length > 0" class="mt-3">
<h3 class="font-bold mb-2">Характеристики</h3>
<div class="space-y-6">
<div class="overflow-x-auto">
<table class="table table-xs">
<tbody>
<template v-for="attrGroup in product.attribute_groups" :key="attrGroup.attribute_group_id">
<tr class="bg-base-200 font-semibold">
<td colspan="2">{{ attrGroup.name }}</td>
</tr>
<tr v-for="attr in attrGroup.attribute" :key="attr.attribute_id">
<td class="w-1/3">{{ attr.name }}</td>
<td>{{ attr.text }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="product.product_id"
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col gap-2 border-t-1 border-t-base-300">
<template v-if="settings.store_enabled">
<div class="text-error">
{{ error }}
</div>
<div v-if="canAddToCart === false" class="text-error text-center text-xs mt-1">
Выберите обязательные опции
</div>
<div class="flex gap-2">
<div class="flex-1">
<button
class="btn btn-primary btn-lg w-full"
:class="isInCart ? 'btn-success' : 'btn-primary'"
:disabled="cart.isLoading || canAddToCart === false"
@click="onCartBtnClick"
>
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }}
</button>
</div>
<Quantity
v-if="isInCart === false"
:modelValue="quantity"
@update:modelValue="setQuantity"
size="lg"
/>
</div>
</template>
<template v-else>
<button
class="btn btn-primary btn-lg w-full"
:disabled="! product.share"
@click="openProductInMarketplace"
>
<template v-if="product.share">
Открыть товар
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
</template>
<template>Товар недоступен</template>
</button>
</template>
</div>
<ProductNotFound v-else/>
<FullScreenImageViewer
v-if="isFullScreen"
:images="product.images"
:activeIndex="initialFullScreenIndex"
@close="closeFullScreen"
/>
<LoadingFullScreen v-if="isLoading"/>
</div>
</template>
<script setup>
import {computed, onMounted, onUnmounted, ref} from "vue";
import {useRoute, useRouter} from 'vue-router'
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
import {useCartStore} from "../stores/CartStore.js";
import Quantity from "../components/Quantity.vue";
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
import {apiFetch} from "@/utils/ftch.js";
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
import ProductNotFound from "@/components/ProductNotFound.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const productId = computed(() => route.params.id);
const product = ref({});
const cart = useCartStore();
const quantity = ref(1);
const error = ref('');
const router = useRouter();
const isInCart = ref(false);
const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить');
const isFullScreen = ref(false);
const initialFullScreenIndex = ref(0);
const isLoading = ref(false);
const settings = useSettingsStore();
const yaMetrika = useYaMetrikaStore();
const canAddToCart = computed(() => {
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
return true;
}
const required = product.value.options.filter(item => {
return SUPPORTED_OPTION_TYPES.includes(item.type)
&& item.required === true
&& !item.value;
});
return required.length === 0;
});
function showFullScreen(index) {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
isFullScreen.value = true;
initialFullScreenIndex.value = index;
document.body.style.overflow = 'hidden';
history.pushState({fullscreen: true}, '');
}
function closeFullScreen() {
isFullScreen.value = false;
document.body.style.overflow = '';
}
async function onCartBtnClick() {
try {
error.value = '';
if (isInCart.value === false) {
await cart.addProduct(productId.value, product.value.name, product.value.price, quantity.value, product.value.options);
isInCart.value = true;
window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
yaMetrika.reachGoal(YA_METRIKA_GOAL.ADD_TO_CART, {
price: product.value.final_price_numeric,
currency: product.value.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"add": {
"products": [
{
"id": product.value?.id,
"name": product.value?.name,
"price": product.value?.final_price_numeric,
"brand": product.value?.manufacturer,
"category": product.value?.category?.name,
"quantity": 1,
"list": "Выдача категории",
"position": 2
}
]
}
}
});
} else {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
await router.push({'name': 'cart'});
}
} catch (e) {
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
error.value = e.message;
}
}
function openProductInMarketplace() {
if (!product.value.share) {
return;
}
yaMetrika.reachGoal(YA_METRIKA_GOAL.PRODUCT_OPEN_EXTERNAL, {
price: product.value?.final_price_numeric,
currency: product.value?.currency,
});
window.Telegram.WebApp.openLink(product.value.share, {try_instant_view: false});
}
function setQuantity(newQuantity) {
quantity.value = newQuantity;
window.Telegram.WebApp.HapticFeedback.selectionChanged();
}
let canVibrate = true;
function onPopState() {
if (isFullScreen.value) {
closeFullScreen();
} else {
// пусть Vue Router сам обработает
router.back();
}
}
const swiperEl = ref(null);
onUnmounted(() => {
window.removeEventListener('popstate', onPopState);
});
onMounted(async () => {
isLoading.value = true;
try {
const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
product.value = data;
window.document.title = data.name;
yaMetrika.pushHit(route.path, {
title: data.name,
params: {
'Название товара': data.name,
'ИД товара': data.product_id,
'Цена': data.price,
},
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_PRODUCT, {
price: data.final_price_numeric,
currency: data.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": settings.currency_code,
"detail": {
"products": [
{
"id": data.product_id,
"name": data.name,
"price": data.final_price_numeric,
"brand": data.manufacturer,
"category": data.category?.name,
}
]
}
}
});
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
window.addEventListener('popstate', onPopState);
swiperEl.value.addEventListener('swiperslidermove', (event) => {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
}, 50);
});
Object.assign(swiperEl.value, {
injectStyles: [`
.swiper-pagination {
position: relative;
padding-top: 15px;
}
`],
pagination: {
dynamicBullets: true,
clickable: true,
},
});
swiperEl.value.initialize();
});
</script>

View File

@@ -1,87 +0,0 @@
<template>
<div ref="goodsRef" class="pb-10">
<ProductsList
:products="productsStore.products.data"
:hasMore="productsStore.products.meta.hasMore"
:isLoading="productsStore.isLoading"
:isLoadingMore="productsStore.isLoadingMore"
:categoryName="category?.name"
@loadMore="productsStore.loadMore"
/>
</div>
</template>
<script setup>
import ProductsList from "@/components/ProductsList.vue";
import {onMounted, ref} from "vue";
import {useRoute} from "vue-router";
import {useProductsStore} from "@/stores/ProductsStore.js";
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
defineOptions({
name: 'Products'
});
const route = useRoute();
const productsStore = useProductsStore();
const categoriesStore = useCategoriesStore();
const yaMetrika = useYaMetrikaStore();
const categoryId = route.params.category_id ?? null;
const category = ref(null);
onMounted(async () => {
console.debug("[Category] Category Products Mounted");
console.debug("[Category] Load products for category: ", categoryId);
category.value = await categoriesStore.findCategoryById(categoryId);
console.debug("[Category] Category Name: ", category.value?.name);
window.document.title = `${category.value?.name ?? 'Неизвестная категория'}`;
yaMetrika.pushHit(route.path, {
title: `${category.value?.name ?? 'Неизвестная категория'}`,
});
if (productsStore.filtersFullUrl === route.fullPath) {
await productsStore.loadProducts(productsStore.filters ?? {
operand: "AND",
rules: {
RULE_PRODUCT_CATEGORIES: {
criteria: {
product_category_ids: {
type: "product_categories",
params: {
operator: "contains",
value: [
categoryId
]
}
}
}
}
},
});
} else {
productsStore.reset();
productsStore.filtersFullUrl = route.fullPath;
await productsStore.loadProducts({
operand: "AND",
rules: {
RULE_PRODUCT_CATEGORIES: {
criteria: {
product_category_ids: {
type: "product_categories",
params: {
operator: "contains",
value: [
categoryId
]
}
}
}
}
},
});
}
});
</script>

View File

@@ -1,94 +0,0 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-20">
<h2 class="text-2xl mb-5 text-center">Поиск</h2>
<div class="w-full">
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
ref="searchInput"
type="search"
class="grow input-lg"
placeholder="Поиск по магазину"
v-model="searchStore.search"
@search="debouncedSearch"
@input="debouncedSearch"
/>
</label>
</div>
<div v-if="searchStore.isLoading === false && searchStore.products.data.length > 0">
<RouterLink
v-for="product in searchStore.products.data"
:key="product.id"
class="flex mb-5"
:to="{name: 'product.show', params: {id: product.id}}"
>
<div v-if="product.images && product.images.length > 0" class="avatar">
<div class="w-24 rounded">
<img :src="product.images[0].url" :alt="product.images[0].alt"/>
</div>
</div>
<div class="ml-5 p-0">
<h2 class="card-title">{{ product.name }}</h2>
<p>{{ product.price }}</p>
</div>
</RouterLink>
</div>
<div v-if="searchStore.isLoading === true">
<div v-for="n in 3" class="flex w-full gap-4 mb-3">
<div class="skeleton h-32 w-32"></div>
<div class="flex flex-col gap-2 w-full">
<div class="skeleton h-4 w-full"></div>
<div class="skeleton h-4 w-28"></div>
<div class="skeleton h-4 w-28"></div>
</div>
</div>
</div>
<div
v-if="searchStore.isSearchPerformed && searchStore.isLoading === false && searchStore.products.data.length === 0"
class="flex flex-col items-center justify-center text-center py-16"
>
<span class="text-5xl mb-4">🛒</span>
<h2 class="text-xl font-semibold mb-2">Товары не найдены</h2>
<p class="text-sm mb-4">Попробуйте изменить или уточнить запрос</p>
</div>
</div>
</template>
<script setup>
import {useSearchStore} from "@/stores/SearchStore.js";
import {useDebounceFn} from "@vueuse/core";
import {onMounted, ref} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useRoute} from "vue-router";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
const route = useRoute();
const yaMetrika = useYaMetrikaStore();
const searchStore = useSearchStore();
const searchInput = ref(null);
const debouncedSearch = useDebounceFn(() => searchStore.performSearch(), 500);
onMounted(async () => {
window.document.title = 'Поиск';
yaMetrika.pushHit(route.path, {
title: 'Поиск',
});
yaMetrika.reachGoal(YA_METRIKA_GOAL.VIEW_SEARCH);
});
</script>

View File

@@ -1,16 +0,0 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
gridTemplateRows: {
'[auto,auto,1fr]': 'auto auto 1fr',
},
},
},
plugins: [
require("daisyui"),
],
};

View File

@@ -1,51 +0,0 @@
import {defineConfig} from "vite";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import {fileURLToPath, URL} from 'node:url';
export default defineConfig({
plugins: [
tailwindcss(),
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('swiper-'),
},
},
}),
],
base: '/image/catalog/tgshopspa/',
build: {
outDir: '../module/oc_telegram_shop/upload/image/catalog/tgshopspa',
emptyOutDir: true,
sourcemap: true,
manifest: true,
minify: 'terser',
reportCompressedSize: true,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: true,
allowedHosts: ["tg.nikitakiselev.ru"],
proxy: {
'/index.php': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: path => path,
}
}
},
});