Compare commits

..

4 Commits

Author SHA1 Message Date
goro
b59c7cef9b updated stuff 2026-04-18 11:52:45 +03:00
goro
0f2628928b added bunch of cards and adjust new 7tv emote 2026-04-12 10:22:47 +03:00
goro
a6d3fd5098 chores 2026-04-12 10:22:00 +03:00
goro
e166283752 remove axios 2026-04-05 09:53:02 +03:00
38 changed files with 1739 additions and 760 deletions

View File

@ -10,16 +10,16 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.9",
"@headlessui/react": "^2.2.9",
"@reduxjs/toolkit": "^1.9.5",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"emojibase": "^15.0.0",
"interweave": "^13.1.0",
"interweave-autolink": "^5.1.0",
"interweave-emoji": "^7.0.0",
"lucide-react": "^0.510.0",
"lucide-react": "^1.7.0",
"preact": "^10.16.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.16.0",

273
pnpm-lock.yaml generated
View File

@ -11,15 +11,15 @@ importers:
'@floating-ui/react':
specifier: ^0.26.9
version: 0.26.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@headlessui/react':
specifier: ^2.2.9
version: 2.2.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@reduxjs/toolkit':
specifier: ^1.9.5
version: 1.9.7(react-redux@8.1.3(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(redux@4.2.1))(react@18.2.0)
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@18.2.0)
axios:
specifier: ^1.5.0
version: 1.6.7
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@ -39,8 +39,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0(interweave@13.1.0(react@18.2.0))(react@18.2.0)
lucide-react:
specifier: ^0.510.0
version: 0.510.0(react@18.2.0)
specifier: ^1.7.0
version: 1.7.0(react@18.2.0)
preact:
specifier: ^10.16.0
version: 10.19.3
@ -400,18 +400,36 @@ packages:
'@floating-ui/core@1.6.0':
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.6.0':
resolution: {integrity: sha512-SZ0BEXzsaaS6THZfZJUcAobbZTD+MvfGM42bxgeg0Tnkp4/an/avqwAXiVLsFtIBZtfsx3Ymvwx0+KnnhdA/9g==}
'@floating-ui/dom@1.6.3':
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/react-dom@2.0.8':
resolution: {integrity: sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.9':
resolution: {integrity: sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==}
peerDependencies:
@ -421,6 +439,16 @@ packages:
'@floating-ui/utils@0.2.1':
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@headlessui/react@2.2.9':
resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -487,6 +515,43 @@ packages:
preact: ^10.4.0
vite: '>=2.0.0'
'@react-aria/focus@3.21.5':
resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.27.1':
resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.10':
resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.33.1':
resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/flags@3.1.2':
resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==}
'@react-stately/utils@3.11.0':
resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.33.1':
resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@reduxjs/toolkit@1.9.7':
resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==}
peerDependencies:
@ -506,6 +571,9 @@ packages:
resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
engines: {node: '>= 8.0.0'}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@tanstack/query-core@5.90.20':
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
@ -514,6 +582,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.23':
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@types/hoist-non-react-statics@3.3.5':
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
@ -571,9 +648,6 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.4.17:
resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==}
engines: {node: ^10 || ^12 || >=14}
@ -581,9 +655,6 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.6.7:
resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
@ -658,10 +729,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@ -704,10 +771,6 @@ packages:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -795,23 +858,10 @@ packages:
find-root@1.1.0:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
follow-redirects@1.15.5:
resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
foreground-child@3.1.1:
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
engines: {node: '>=14'}
form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@ -966,8 +1016,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.510.0:
resolution: {integrity: sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q==}
lucide-react@1.7.0:
resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -986,14 +1036,6 @@ packages:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@9.0.3:
resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==}
engines: {node: '>=16 || 14 >=14.17'}
@ -1133,9 +1175,6 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -1344,6 +1383,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.3.3:
resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
engines: {node: '>=14.17'}
@ -1386,6 +1428,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -1741,6 +1788,10 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.1
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.6.0':
dependencies:
'@floating-ui/core': 1.6.0
@ -1751,12 +1802,31 @@ snapshots:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/react-dom@2.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/dom': 1.6.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react-dom@2.1.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react@0.26.28(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@floating-ui/utils': 0.2.11
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tabbable: 6.2.0
'@floating-ui/react@0.26.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.0.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -1767,6 +1837,18 @@ snapshots:
'@floating-ui/utils@0.2.1': {}
'@floating-ui/utils@0.2.11': {}
'@headlessui/react@2.2.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-aria/focus': 3.21.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-aria/interactions': 3.27.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@tanstack/react-virtual': 3.13.23(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
use-sync-external-store: 1.6.0(react@18.2.0)
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -1850,6 +1932,55 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@react-aria/focus@3.21.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@react-aria/interactions': 3.27.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-aria/utils': 3.33.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-types/shared': 3.33.1(react@18.2.0)
'@swc/helpers': 0.5.21
clsx: 2.1.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@react-aria/interactions@3.27.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@18.2.0)
'@react-aria/utils': 3.33.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-stately/flags': 3.1.2
'@react-types/shared': 3.33.1(react@18.2.0)
'@swc/helpers': 0.5.21
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@react-aria/ssr@3.9.10(react@18.2.0)':
dependencies:
'@swc/helpers': 0.5.21
react: 18.2.0
'@react-aria/utils@3.33.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@18.2.0)
'@react-stately/flags': 3.1.2
'@react-stately/utils': 3.11.0(react@18.2.0)
'@react-types/shared': 3.33.1(react@18.2.0)
'@swc/helpers': 0.5.21
clsx: 2.1.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@react-stately/flags@3.1.2':
dependencies:
'@swc/helpers': 0.5.21
'@react-stately/utils@3.11.0(react@18.2.0)':
dependencies:
'@swc/helpers': 0.5.21
react: 18.2.0
'@react-types/shared@3.33.1(react@18.2.0)':
dependencies:
react: 18.2.0
'@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(redux@4.2.1))(react@18.2.0)':
dependencies:
immer: 9.0.21
@ -1867,6 +1998,10 @@ snapshots:
estree-walker: 2.0.2
picomatch: 2.3.1
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
'@tanstack/query-core@5.90.20': {}
'@tanstack/react-query@5.90.21(react@18.2.0)':
@ -1874,6 +2009,14 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 18.2.0
'@tanstack/react-virtual@3.13.23(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@tanstack/virtual-core': 3.13.23
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@tanstack/virtual-core@3.13.23': {}
'@types/hoist-non-react-statics@3.3.5':
dependencies:
'@types/react': 18.2.48
@ -1931,8 +2074,6 @@ snapshots:
arg@5.0.2: {}
asynckit@0.4.0: {}
autoprefixer@10.4.17(postcss@8.4.33):
dependencies:
browserslist: 4.22.2
@ -1943,14 +2084,6 @@ snapshots:
postcss: 8.4.33
postcss-value-parser: 4.2.0
axios@1.6.7:
dependencies:
follow-redirects: 1.15.5
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.23.9
@ -2026,10 +2159,6 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {}
convert-source-map@1.9.0: {}
@ -2068,8 +2197,6 @@ snapshots:
dependencies:
ms: 2.1.2
delayed-stream@1.0.0: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}
@ -2170,19 +2297,11 @@ snapshots:
find-root@1.1.0: {}
follow-redirects@1.15.5: {}
foreground-child@3.1.1:
dependencies:
cross-spawn: 7.0.3
signal-exit: 4.1.0
form-data@4.0.0:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
fraction.js@4.3.7: {}
fsevents@2.3.2:
@ -2305,7 +2424,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.510.0(react@18.2.0):
lucide-react@1.7.0(react@18.2.0):
dependencies:
react: 18.2.0
@ -2322,12 +2441,6 @@ snapshots:
braces: 3.0.2
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@9.0.3:
dependencies:
brace-expansion: 2.0.1
@ -2446,8 +2559,6 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
proxy-from-env@1.1.0: {}
queue-microtask@1.2.3: {}
react-dom@18.2.0(react@18.2.0):
@ -2671,6 +2782,8 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1: {}
typescript@5.3.3: {}
undici-types@7.18.2: {}
@ -2702,6 +2815,10 @@ snapshots:
dependencies:
react: 18.2.0
use-sync-external-store@1.6.0(react@18.2.0):
dependencies:
react: 18.2.0
util-deprecate@1.0.2: {}
vite@4.5.2(@types/node@25.5.0):

View File

@ -10,6 +10,7 @@ import { PersistGate } from 'redux-persist/integration/react'
import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute'
import { getRoutes } from './routes';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ScrollToTop from './components/ScrollToTop'
const queryClient = new QueryClient({
defaultOptions: {
@ -28,6 +29,7 @@ export function App() {
<Provider store={store}>
<PersistGate persistor={persistore}>
<Router>
<ScrollToTop />
<Routes>
<Route path='/login' element={<Login />} />
<Route element={<DefaultLayout />}>

View File

@ -0,0 +1,155 @@
import { useState } from 'preact/hooks';
import { Dialog, DialogPanel, DialogTitle, Tab, TabGroup, TabList, TabPanel, TabPanels, Transition, TransitionChild } from '@headlessui/react';
export interface FacilityItem {
text: string;
}
export interface FacilityCategory {
title: string;
items: FacilityItem[];
}
interface FacilitiesCardProps {
title?: string;
left: FacilityCategory[];
middle: FacilityCategory[];
right: FacilityCategory[];
seeMoreShow?: boolean;
}
function CategorySection({ category }: { category: FacilityCategory }) {
return (
<div className="mb-6">
<h4 className="text-[#C74F28] underline font-semibold mb-3">{category.title}</h4>
<ol className="list-decimal list-outside pl-5 flex flex-col gap-2.5">
{category.items.map((item, i) => (
<li key={i} className="text-md">
{item.text}
</li>
))}
</ol>
</div>
);
}
export function FacilitiesCard({ title = 'Facilities & Amenities', left, middle, right, seeMoreShow }: FacilitiesCardProps) {
const [dialogOpen, setDialogOpen] = useState(false);
// Flatten all categories from the 3 columns into a single ordered list for tabs
const allCategories = [...left, ...middle, ...right];
function handleSeeMore() {
setDialogOpen(true);
}
return (
<>
<div className="w-full mt-4 bg-black/[0.27] p-6 rounded-xl">
<h3 className="text-center font-bold text-2xl mb-6">{title}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={'border-r-2 border-x-gray-700'}>
{left.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
<div className={'border-r-2 border-x-gray-700'}>
{middle.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
<div className={''}>
{right.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
</div>
{seeMoreShow !== undefined && (
<div className="flex justify-end mt-4">
<button
onClick={handleSeeMore}
className="text-sm text-tertiary underline underline-offset-2 hover:text-white transition-colors"
>
See more
</button>
</div>
)}
</div>
{/* Dialog */}
<Transition show={dialogOpen}>
<Dialog onClose={() => setDialogOpen(false)} className="relative z-50">
{/* Backdrop */}
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/70" />
</TransitionChild>
{/* Panel container */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="bg-[#1a1a1a] text-white rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden relative">
{/* Close button — top right */}
<button
onClick={() => setDialogOpen(false)}
className="absolute top-5 right-5 text-tertiary hover:text-white transition-colors text-2xl leading-none w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 z-10"
>
&times;
</button>
{/* Header */}
<div className="px-8 pt-8 pb-0 flex-shrink-0">
<DialogTitle className="text-4xl font-bold mb-5">{title}</DialogTitle>
{/* Pill tabs */}
<TabGroup>
<TabList className="flex flex-row gap-1 bg-[#2a2a2a] rounded-xl p-1 w-fit overflow-x-auto [-webkit-overflow-scrolling:touch] [&::-webkit-scrollbar]:hidden">
{allCategories.map((cat, i) => (
<Tab
key={i}
className="whitespace-nowrap px-5 py-2 text-sm font-medium text-tertiary rounded-lg transition-colors outline-none
data-[selected]:bg-black data-[selected]:text-white
hover:text-white"
>
{cat.title}
</Tab>
))}
</TabList>
{/* Tab panels */}
<TabPanels className="overflow-y-auto py-6 flex-1">
{allCategories.map((cat, i) => (
<TabPanel key={i}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-12 gap-y-4">
{cat.items.map((item, j) => (
<div key={j} className="flex items-start gap-3">
<span className="text-sm leading-relaxed">{item.text}</span>
</div>
))}
</div>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</>
);
}
export default FacilitiesCard;

View File

@ -1,3 +1,8 @@
import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { RestaurantIcon } from '../../Icons/RestaurantIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
interface RatingData {
score: number;
count: number;
@ -41,117 +46,77 @@ const RatingsCard = <T,>({
};
return (
<div className="flex flex-col gap-1 mt-2 items-center">
{/* Critics Score Section */}
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full">
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full">
<div className="font-bold text-xs mb-2 text-center">CRITICS SCORE</div>
<div className="text-4xl text-center my-2">
{calculateScore(criticData.score, criticData.count)}
<div className="flex flex-col gap-1 items-center">
<div className="w-full mt-2 flex flex-col md:flex-row gap-4">
{/* Critics column */}
<div className="flex-1 flex flex-col gap-3">
<div className="bg-secondary rounded-xl px-4 py-3 flex items-center justify-between">
<div>
<div className="text-base font-bold tracking-wide">CRITICS SCORE</div>
{criticData.count !== 0 && (
<div className="text-tertiary mt-0.5 text-xs">Based on {formatCount(criticData.count)} reviews</div>
)}
</div>
<div className="w-11 h-11 rounded-full bg-black flex items-center justify-center text-lg font-bold flex-shrink-0">
{calculateScore(criticData.score, criticData.count)}
</div>
</div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2">
<div
className="h-1 bg-brand-green"
style={{ width: `${criticData.count !== 0 ? criticData.score : 0}%` }}
/>
</div>
{criticData.count !== 0 && (
<div className="text-sm text-center">
Based on {formatCount(criticData.count)} reviews
{criticDetails && (
<div className="grid grid-cols-2 gap-2">
{([
{ label: 'Taste', value: criticDetails.environment, icon: <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Cleanliness', value: criticDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Service', value: criticDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Facilities', value: criticDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
] as const).map((item) => (
<div key={item.label} className="border border-gray-600 rounded-xl px-3 py-2 flex items-center gap-3">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-xs">{item.label}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value}</div>
<div className="text-tertiary text-xs">Based on {formatCount(criticData.count)} reviews</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Critics Detail Cards */}
{criticDetails && (
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.environment}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.environment}%` }} />
</div>
</div>
{/* Users column */}
<div className="flex-1 flex flex-col gap-3">
<div className="bg-secondary rounded-xl px-4 py-3 flex items-center justify-between">
<div>
<div className="text-base font-bold tracking-wide">USERS SCORE</div>
{userData.count !== 0 && (
<div className="text-tertiary mt-0.5 text-xs">Based on {formatCount(userData.count)} reviews</div>
)}
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.facility}%` }} />
</div>
</div>
<div className="w-11 h-11 rounded-full bg-black flex items-center justify-center text-lg font-bold flex-shrink-0">
{calculateScore(userData.score, userData.count)}
</div>
</div>
)}
</div>
{/* Users Score Section */}
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full">
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full">
<div className="font-bold text-xs mb-2 text-center">USERS SCORE</div>
<div className="text-4xl text-center my-2">
{calculateScore(userData.score, userData.count)}
</div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2">
<div
className="h-1 bg-brand-green"
style={{ width: `${userData.count !== 0 ? userData.score / userData.count : 0}%` }}
/>
</div>
{userData.count !== 0 && (
<div className="text-sm text-center">
Based on {formatCount(userData.count)} reviews
{userDetails && (
<div className="grid grid-cols-2 gap-2">
{([
{ label: 'Taste', value: userDetails.environment, icon: <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Cleanliness', value: userDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Service', value: userDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Facilities', value: userDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
] as const).map((item) => (
<div key={item.label} className="border border-gray-600 rounded-xl px-3 py-2 flex items-center gap-3">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-xs">{item.label}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value}</div>
<div className="text-tertiary text-xs">Based on {formatCount(userData.count)} reviews</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Users Detail Cards */}
{userDetails && (
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.environment}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.environment}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.facility}%` }} />
</div>
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@ -0,0 +1,79 @@
import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { RestaurantIcon } from '../../Icons/RestaurantIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
interface RatingData {
score: number;
count: number;
}
interface DetailRatings {
environment: number;
cleanliness: number;
price: number;
facility: number;
}
interface RatingsCardRowProps<T> {
data: T;
title: string;
getRatingData: (data: T) => RatingData;
getDetails?: (data: T) => DetailRatings;
}
const RatingsCardRow = <T,>({
data,
title,
getRatingData,
getDetails,
}: RatingsCardRowProps<T>) => {
const ratingData = getRatingData(data);
const details = getDetails?.(data);
const formatCount = (count: number): string | number =>
count >= 1000 ? `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k` : count;
const calculateScore = (score: number, count: number): string | number =>
count !== 0 ? Math.floor(score / count) : 'NR';
const score = calculateScore(ratingData.score, ratingData.count);
return (
<div className="flex flex-col md:flex-row gap-3 items-stretch">
<div className="bg-secondary rounded-xl px-5 py-5 flex-shrink-0 md:w-[320px] flex flex-col gap-2 self-center">
<div className="flex flex-row items-center justify-between gap-3">
<div className="text-2xl font-bold tracking-wide leading-tight">{title}</div>
<div className="w-16 h-16 rounded-full bg-black flex items-center justify-center text-3xl font-bold flex-shrink-0">
{score}
</div>
</div>
{ratingData.count !== 0 && (
<div className="text-tertiary text-sm">Based on {formatCount(ratingData.count)} reviews</div>
)}
</div>
{details && (
<div className="grid grid-cols-2 gap-3 flex-1">
{([
{ label: 'Rasa', value: details.environment, icon: <RestaurantIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Kebersihan', value: details.cleanliness, icon: <CleanlinessIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Pelayanan', value: details.price, icon: <ServiceIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Fasilitas', value: details.facility, icon: <FacilityIcon className="w-10 h-10" strokeWidth={1.1} /> },
] as const).map((item) => (
<div key={item.label} className="border border-gray-700 rounded-xl px-4 py-3 flex items-center gap-4">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-sm">{item.label}</div>
<div className="text-4xl font-bold leading-none my-0.5">{item.value}</div>
<div className="text-tertiary text-xs">Based on {formatCount(ratingData.count)} reviews</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default RatingsCardRow;

View File

@ -0,0 +1,82 @@
interface DaySchedule {
day: 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday';
open: string; // e.g. "7.00 AM"
close: string; // e.g. "21.00 PM"
closed?: boolean;
}
interface ScheduleCardProps {
schedules: DaySchedule[];
onSuggestEdit?: () => void;
}
const DAYS: DaySchedule['day'][] = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
];
function getCurrentDay(): DaySchedule['day'] {
return DAYS[new Date().getDay()];
}
function isOpenNow(schedules: DaySchedule[]): boolean {
const today = getCurrentDay();
const todaySchedule = schedules.find((s) => s.day === today);
if (!todaySchedule || todaySchedule.closed) return false;
const now = new Date();
const [openHour, openMin] = todaySchedule.open.replace(/[^0-9.]/g, '').split('.').map(Number);
const [closeHour, closeMin] = todaySchedule.close.replace(/[^0-9.]/g, '').split('.').map(Number);
const openIsPm = todaySchedule.open.toUpperCase().includes('PM');
const closeIsPm = todaySchedule.close.toUpperCase().includes('PM');
const openTotal = (openIsPm && openHour !== 12 ? openHour + 12 : openHour) * 60 + (openMin || 0);
const closeTotal = (closeIsPm && closeHour !== 12 ? closeHour + 12 : closeHour) * 60 + (closeMin || 0);
const nowTotal = now.getHours() * 60 + now.getMinutes();
return nowTotal >= openTotal && nowTotal < closeTotal;
}
export function ScheduleCard({ schedules, onSuggestEdit }: ScheduleCardProps) {
const today = getCurrentDay();
return (
<div className="bg-black/[0.27] rounded-xl mt-2 px-4 py-4 w-full">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold">Hours</h3>
</div>
<button
onClick={onSuggestEdit}
className="text-sm underline underline-offset-2 hover:text-white transition-colors text-tertiary mt-1"
>
Suggest an edit
</button>
</div>
{/* <hr className="border-[#38444d] mb-4 mt-2" /> */}
<div className="flex flex-col gap-1">
{DAYS.map((day) => {
const schedule = schedules.find((s) => s.day === day);
const isToday = day === today;
return (
<div
key={day}
className={`flex items-center justify-between text-sm ${isToday ? 'font-bold text-white' : 'text-tertiary'}`}
>
<span>{day}</span>
<span>
{!schedule || schedule.closed
? 'Closed'
: `${schedule.open} - ${schedule.close}`}
</span>
</div>
);
})}
</div>
</div>
);
}
export default ScheduleCard;

View File

@ -6,11 +6,13 @@ import { EmojiMatcher, PathConfig } from 'interweave-emoji';
class SevenTVMatcher extends Matcher {
private emotes: Record<string, string> = {
'booba': '01F6N31ETR0004P7N4A9PKS5X9',
'pepeJAM': '5f1f0ea5cf6d2144653d7501',
'OMEGALUL': '5f4b3bc28fb088567e5cbb3b',
'pepeJAM': '01EZY967K0000CYST6006V20T8',
'OMEGALUL': '01F00Z3A9G0007E4VV006YKSK9',
'monkaS': '01F78CHJ2G0005TDSTZFBDGMK4',
'Sadge': '5f1f0f61b5e9d35e9a2f8a0e',
'PogU': '5f1f0c1235c7c40e6a3f9c1b',
'Sadge': '01EZPG1FN80001SNAW00ADK2DY',
'PogU': '01F6M3N17G000B5V5G2M2RYJN7',
'skateParkGe': '01GMKPJQJR0008GS0R6JQ1670A',
'crashout': '01KM204X52XJ5GAA1TAP6AZWGC'
};
replaceWith(match: string): Node {
@ -20,12 +22,12 @@ class SevenTVMatcher extends Matcher {
if (emoteId) {
return (
<img
src={`https://cdn.7tv.app/emote/${emoteId}/3x.avif`}
src={`https://cdn.7tv.app/emote/${emoteId}/4x.avif`}
alt={emoteName}
title={emoteName}
style={{
display: 'inline-block',
height: '28px',
height: '32px',
verticalAlign: 'middle',
margin: '0 2px'
}}

View File

@ -9,6 +9,7 @@ import { getSearchLocationService } from "../../services/locations";
import { Link, useNavigate } from "react-router-dom";
import { ReactSelectData } from "src/types/common";
import { useDispatch, useSelector } from "react-redux";
import { DEFAULT_AVATAR_IMG } from "../../constants/default";
function Header() {
@ -114,16 +115,17 @@ function Header() {
<Link to={user.username ? '#' : '/login'} onClick={() => user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''}>
<img
loading={'lazy'}
style={{ width: 40, borderRadius: 15 }}
src={'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
style={{ width: 40 }}
className={'rounded-lg'}
src={user.avatar_picture ? user.avatar_picture : DEFAULT_AVATAR_IMG}
/>
</Link>
{user.username &&
<div className={'profile-dropdown-img bg-secondary text-left'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
<Link to={'/user/profile'}><div className={'p-2'}>Profile</div></Link>
<Link to={'#'}><div className={'p-2'}>Feed</div></Link>
<Link to={'/add-location'}><div className={'p-2'}>Add location</div></Link>
<Link to={'/add-location'}><div className={'p-2'}>Settings</div></Link>
<Link onClick={() => setPageState({ ...pageState, profileMenu: false })} to={'/user/profile'}><div className={'p-2'}>Profile</div></Link>
<Link onClick={() => setPageState({ ...pageState, profileMenu: false })} to={'#'}><div className={'p-2'}>Feed</div></Link>
<Link onClick={() => setPageState({ ...pageState, profileMenu: false })} to={'/add-location'}><div className={'p-2'}>Add location</div></Link>
<Link onClick={() => setPageState({ ...pageState, profileMenu: false })} to={'/user/setting'}><div className={'p-2'}>Settings</div></Link>
<Link to={'#'} onClick={handleLogout}><div className={'p-2'}>Logout</div></Link>
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
@ -187,23 +189,23 @@ function Header() {
{dropdown &&
<a href="/" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Home</a>
}
<Link to="/best-places" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Top Places</Link>
<Link to="/discover" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Discover</Link>
<Link to="/stories" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Stories</Link>
<Link to="/news-events" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>News / Events</Link>
<Link to="/best-places" onClick={() => setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Top Places</Link>
<Link to="/discover" onClick={() => setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Discover</Link>
<Link to="/stories" onClick={() => setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Stories</Link>
<Link to="/news-events" onClick={() => setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>News / Events</Link>
<Link to="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Forum</Link>
<div className={'profile-container'}>
<Link to={user.username ? '#' : '/login'} onClick={() => user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>{user.username ? user.username : 'Sign in'}</Link>
{user && screen.width > 600 &&
<div className={'profile-dropdown bg-secondary ml-6'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
<Link to={'/add-location'} onClick={() => setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}><div className={'p-1'}>Add location</div></Link>
<Link to={'/user/profile'}><div className={'p-1'}>Profile</div></Link>
<Link to={'#'}><div className={'p-1'}>Feed</div></Link>
<Link to={'/user/settings'}><div className={'p-1'}>Settings</div></Link>
<Link to={'/user/profile'} onClick={() => setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}><div className={'p-1'}>Profile</div></Link>
<Link to={'#'} onClick={() => setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}><div className={'p-1'}>Feed</div></Link>
<Link to={'/user/settings'} onClick={() => setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}><div className={'p-1'}>Settings</div></Link>
{user.is_admin &&
<Link to={'/submissions'} ><div className={'p-1'}>Submissions</div></Link>
<Link to={'/submissions'} onClick={() => setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}><div className={'p-1'}>Submissions</div></Link>
}
<Link to={'#'} onClick={handleLogout}><div className={'p-1'}>Logout</div></Link>
<Link to={'#'} onClick={handleLogout} ><div className={'p-1'}>Logout</div></Link>
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
</div>

View File

@ -0,0 +1,39 @@
interface CleanlinessIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function CleanlinessIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: CleanlinessIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg
width={width}
height={height}
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke={stroke}
strokeWidth={sw}
>
<path d="M17.1746 11.9569C16.97 11.9818 16.7673 12.0471 16.6727 12.1015L10.0629 15.9107C9.83863 16.04 9.76156 16.3266 9.89078 16.5509C10.02 16.7752 10.3066 16.8522 10.5309 16.723L17.0466 12.9682C17.3659 12.7842 17.7518 12.8874 17.9359 13.2057C18.12 13.5239 18.0173 13.9075 17.698 14.0915L13.4449 16.5427C13.2206 16.6719 13.1435 16.9585 13.2728 17.1828C13.402 17.4071 13.6886 17.4842 13.9129 17.355L18.166 14.9038C18.9209 14.4687 19.184 13.4905 18.7477 12.7364C18.4206 12.1707 17.7886 11.8823 17.1746 11.9569Z" fill={fill} />
<path d="M18.3528 19.0211C18.1481 19.0459 17.9455 19.1113 17.7567 19.22L13.9383 21.4203C13.714 21.5496 13.637 21.8362 13.7662 22.0605C13.8954 22.285 14.1824 22.3621 14.4068 22.2326L18.2247 20.0323C18.5441 19.8483 18.9304 19.9515 19.1145 20.2698C19.2986 20.5881 19.1959 20.9716 18.8766 21.1556C17.3239 22.0504 15.7709 22.9455 14.9857 23.3983C14.9797 23.4018 13.0339 24.5906 11.745 25.0592C11.1005 25.2936 10.4287 25.452 9.9041 25.4364C9.37953 25.4208 9.04959 25.2834 8.82536 24.8957C8.69858 24.6766 8.42076 24.5977 8.19775 24.7175L7.28424 25.208C7.05606 25.3305 6.97044 25.6149 7.09308 25.843C7.21557 26.0709 7.49959 26.1565 7.7276 26.0341L8.30288 25.7253C8.72015 26.1549 9.29824 26.3565 9.87642 26.3737C10.6003 26.3952 11.3582 26.1975 12.0655 25.9403C13.48 25.4259 15.4718 24.1996 15.4718 24.1996C16.2393 23.7578 17.7919 22.8627 19.3446 21.9679C20.0995 21.5328 20.3621 20.5547 19.9259 19.8005C19.5987 19.2349 18.9668 18.9464 18.3528 19.0211Z" fill={fill} />
<path d="M19.4138 15.8286C19.2091 15.8534 19.0065 15.9188 18.8178 16.0275L14.5508 18.4865C14.4432 18.5486 14.3646 18.6509 14.3324 18.771C14.3002 18.891 14.317 19.019 14.3791 19.1266C14.4412 19.2343 14.5435 19.313 14.6635 19.3453C14.7836 19.3776 14.9115 19.3608 15.0193 19.2988L19.2858 16.8398C19.6051 16.6558 19.9914 16.759 20.1755 17.0773C20.3596 17.3956 20.2569 17.7791 19.9376 17.9631L15.7922 20.352C15.6845 20.4141 15.6058 20.5164 15.5735 20.6364C15.5413 20.7565 15.558 20.8844 15.6201 20.9921C15.6821 21.0998 15.7844 21.1785 15.9045 21.2108C16.0245 21.2431 16.1525 21.2263 16.2602 21.1643L20.4056 18.7754C21.1605 18.3403 21.4231 17.3622 20.9869 16.608C20.6597 16.0424 20.0278 15.7539 19.4138 15.8286Z" fill={fill} />
<path d="M19.72 13.0711C19.5153 13.096 19.3127 13.1613 19.124 13.2701L15.7074 15.239C15.5997 15.3011 15.521 15.4034 15.4887 15.5234C15.4565 15.6435 15.4732 15.7714 15.5352 15.8791C15.5973 15.9869 15.6996 16.0655 15.8197 16.0978C15.9397 16.1301 16.0677 16.1133 16.1754 16.0513L19.592 14.0824C19.9113 13.8984 20.2976 14.0016 20.4817 14.3199C20.6658 14.6381 20.5631 15.0217 20.2438 15.2057L16.8272 17.1746C16.7738 17.2053 16.727 17.2463 16.6895 17.2951C16.6519 17.3439 16.6243 17.3997 16.6084 17.4592C16.5924 17.5187 16.5883 17.5807 16.5963 17.6418C16.6043 17.7029 16.6243 17.7618 16.6551 17.8152C16.7172 17.9228 16.8195 18.0014 16.9396 18.0336C17.0596 18.0658 17.1876 18.049 17.2952 17.9869L20.7118 16.018C21.4667 15.5829 21.7293 14.6047 21.2931 13.8506C20.966 13.2849 20.334 12.9965 19.72 13.0711Z" fill={fill} />
<path d="M11.4344 11.075C11.2542 11.0657 11.0638 11.093 10.8739 11.1537C10.8067 11.1752 10.7451 11.2116 10.6939 11.2601C10.6939 11.2601 9.04205 12.8258 7.54206 14.6832C6.79207 15.6119 6.07721 16.612 5.61642 17.56C5.23999 18.3345 5.02104 19.1068 5.19211 19.7931L4.3223 20.2944C4.21466 20.3566 4.1361 20.4589 4.1039 20.579C4.0717 20.699 4.08849 20.8269 4.15058 20.9346C4.21264 21.0423 4.31495 21.121 4.435 21.1532C4.55505 21.1855 4.68301 21.1688 4.79072 21.1067L5.98841 20.4164C6.04181 20.3857 6.08863 20.3447 6.12617 20.2958C6.16372 20.2469 6.19125 20.1911 6.2072 20.1315C6.22315 20.072 6.2272 20.0099 6.21913 19.9488C6.21105 19.8877 6.191 19.8288 6.16012 19.7754C5.97547 19.4561 6.05367 18.8058 6.45986 17.97C6.86606 17.1343 7.5462 16.1702 8.27131 15.2723C9.64734 13.5684 11.0854 12.1881 11.2381 12.0422C11.3441 12.0244 11.443 12.0153 11.4656 12.0283C11.496 12.0459 11.5364 12.0887 11.5663 12.2026C11.6261 12.4297 11.5345 12.8644 11.4175 13.0184C10.9387 13.5782 10.5609 13.9285 10.2722 14.4029C9.98045 14.8824 9.83615 15.4594 9.82844 16.3126C9.82737 16.4368 9.87571 16.5565 9.96282 16.6451C10.0499 16.7338 10.1687 16.7842 10.293 16.7853C10.4173 16.7865 10.5369 16.7383 10.6257 16.6512C10.7144 16.5642 10.7649 16.4455 10.7662 16.3212C10.773 15.5596 10.8736 15.2186 11.0733 14.8904C11.273 14.5622 11.6297 14.2151 12.1447 13.611C12.1497 13.6049 12.1546 13.5987 12.1593 13.5924C12.5116 13.1345 12.6219 12.5311 12.4729 11.9643C12.3983 11.6809 12.2314 11.3877 11.9335 11.216C11.7845 11.1302 11.6147 11.0843 11.4344 11.075Z" fill={fill} />
<path d="M3.50378 19.5185C3.36003 19.5349 3.21792 19.579 3.08596 19.6521L0.241635 21.2287C0.132891 21.289 0.0525238 21.3899 0.0182114 21.5094C-0.016101 21.6289 -0.00154865 21.7571 0.0586676 21.8658C0.118898 21.9746 0.219855 22.0549 0.339333 22.0893C0.458811 22.1236 0.587026 22.109 0.695776 22.0488L3.54054 20.4718C3.64527 20.4138 3.74479 20.4406 3.79965 20.5354L7.19847 26.4122C7.25333 26.507 7.22618 26.609 7.13793 26.6579L4.29358 28.2345C4.18492 28.2948 4.10464 28.3958 4.07042 28.5152C4.03619 28.6347 4.05081 28.7629 4.11106 28.8716C4.17129 28.9803 4.27225 29.0607 4.39173 29.095C4.51122 29.1293 4.63944 29.1148 4.74819 29.0545L7.59251 27.478C8.13688 27.1762 8.31692 26.4732 8.00991 25.9425L4.61106 20.0661C4.38082 19.668 3.93502 19.4694 3.50378 19.5185Z" fill={fill} />
<path d="M25.2643 11.2005C25.2336 11.1473 25.1928 11.1008 25.1441 11.0634C25.0954 11.0261 25.0399 10.9987 24.9806 10.9828C24.9214 10.9669 24.8596 10.9629 24.7987 10.9709C24.7379 10.9789 24.6793 10.9988 24.6261 11.0295C24.573 11.0602 24.5265 11.101 24.4891 11.1497C24.4518 11.1983 24.4244 11.2539 24.4085 11.3131C24.3926 11.3724 24.3886 11.4342 24.3966 11.495C24.4046 11.5558 24.4245 11.6145 24.4552 11.6676C24.4858 11.7207 24.5267 11.7673 24.5753 11.8046C24.624 11.842 24.6796 11.8694 24.7388 11.8853C24.7981 11.9011 24.8599 11.9052 24.9207 11.8972C24.9815 11.8892 25.0402 11.8693 25.0933 11.8386C25.1464 11.8079 25.193 11.7671 25.2303 11.7184C25.2677 11.6697 25.2951 11.6142 25.3109 11.5549C25.3268 11.4957 25.3309 11.4339 25.3229 11.3731C25.3149 11.3122 25.2949 11.2536 25.2643 11.2005Z" fill={fill} />
<path d="M24.3893 9.68415C24.3273 9.57686 24.2253 9.49857 24.1056 9.46651C23.986 9.43445 23.8585 9.45123 23.7512 9.51317C23.698 9.54384 23.6515 9.58468 23.6141 9.63335C23.5768 9.68201 23.5494 9.73756 23.5335 9.79681C23.5176 9.85607 23.5136 9.91787 23.5216 9.97869C23.5296 10.0395 23.5495 10.0982 23.5802 10.1513C23.6108 10.2044 23.6517 10.251 23.7003 10.2883C23.749 10.3257 23.8046 10.3531 23.8638 10.3689C23.9231 10.3848 23.9849 10.3889 24.0457 10.3809C24.1065 10.3729 24.1652 10.3529 24.2183 10.3223C24.2714 10.2916 24.318 10.2508 24.3553 10.2021C24.3927 10.1534 24.4201 10.0979 24.4359 10.0386C24.4518 9.97937 24.4559 9.91757 24.4479 9.85675C24.4399 9.79593 24.4199 9.73728 24.3893 9.68415Z" fill={fill} />
<path d="M20.3863 6.10566C20.0132 6.1525 19.6431 6.27418 19.2976 6.47591L14.949 9.01531C14.2581 9.41877 13.7898 10.0669 13.596 10.7857C13.499 11.1451 13.4699 11.5232 13.5147 11.899C13.5453 12.156 13.7784 12.3396 14.0354 12.309C14.2926 12.2784 14.4763 12.045 14.4455 11.7878C14.4151 11.5327 14.4346 11.2753 14.5339 10.9072C14.6331 10.5391 14.9481 10.1016 15.4217 9.82501L19.7704 7.28518C20.7177 6.73198 21.8874 7.04748 22.4283 8.00189L22.7678 8.60051C22.8956 8.82561 23.1815 8.90462 23.4067 8.77699C23.6318 8.64926 23.7108 8.36328 23.5832 8.13813L23.2441 7.53951C22.6524 6.49542 21.5056 5.96515 20.3863 6.10566ZM25.7822 12.4501C25.708 12.4526 25.6355 12.4728 25.5707 12.5089C25.3444 12.6348 25.2631 12.9202 25.389 13.1464L27.8535 17.5747C28.3891 18.5371 28.072 19.7344 27.1247 20.2875L22.7761 22.8269C22.3024 23.1035 21.7757 23.1583 21.2959 23.0289C21.056 22.9643 20.8287 22.8533 20.6281 22.6993C20.4228 22.5419 20.1287 22.5806 19.9711 22.7858C19.8133 22.9913 19.8521 23.2857 20.0576 23.4433C20.3565 23.6726 20.695 23.838 21.052 23.9342C21.766 24.1267 22.5579 24.0401 23.2488 23.6366L27.5975 21.0968C28.9793 20.2899 29.4487 18.5131 28.6728 17.1188L26.2082 12.6905C26.1226 12.5367 25.9581 12.4439 25.7822 12.4501Z" fill={fill} />
<path d="M11.8157 2.59091e-05C11.6938 0.00122134 11.5772 0.0498534 11.4905 0.135608C11.4039 0.221363 11.354 0.337495 11.3516 0.459376C11.3232 2.40296 9.75403 3.97214 7.81046 4.00052C7.68822 4.00361 7.57202 4.05434 7.48666 4.1419C7.40129 4.22945 7.35352 4.34689 7.35352 4.46917C7.35352 4.59145 7.40129 4.70889 7.48666 4.79644C7.57202 4.88399 7.68822 4.93472 7.81046 4.93782C9.75403 4.9662 11.3232 6.53581 11.3516 8.47939C11.3516 8.60375 11.401 8.72301 11.4889 8.81094C11.5769 8.89886 11.6961 8.94826 11.8205 8.94826C11.9448 8.94826 12.0641 8.89886 12.152 8.81094C12.2399 8.72301 12.2893 8.60375 12.2893 8.47939C12.3177 6.53581 13.8869 4.9662 15.8305 4.93782C15.9527 4.93473 16.0689 4.88399 16.1543 4.79644C16.2396 4.70889 16.2874 4.59145 16.2874 4.46917C16.2874 4.34689 16.2396 4.22945 16.1543 4.14189C16.0689 4.05434 15.9527 4.00361 15.8305 4.00052C13.8869 3.97214 12.3177 2.40296 12.2893 0.459376C12.2868 0.335847 12.2357 0.218293 12.147 0.132274C12.0583 0.0462552 11.9392 -0.00127221 11.8157 2.59091e-05ZM11.8206 2.37679C12.2655 3.28797 13.002 4.02451 13.9132 4.46939C13.002 4.91426 12.2655 5.6508 11.8206 6.56198C11.3757 5.6508 10.6392 4.91426 9.72798 4.46939C10.6392 4.02451 11.3757 3.28797 11.8206 2.37679Z" fill={fill} />
<path d="M25.6489 2.59091e-05C25.527 0.00133572 25.4105 0.0500184 25.324 0.135761C25.2374 0.221504 25.1876 0.337571 25.1852 0.459376C25.1655 1.81315 24.0744 2.90418 22.7206 2.92395C22.5984 2.92704 22.4822 2.97777 22.3968 3.06532C22.3115 3.15287 22.2637 3.27031 22.2637 3.39259C22.2637 3.51488 22.3115 3.63232 22.3968 3.71987C22.4822 3.80742 22.5984 3.85815 22.7206 3.86124C24.0744 3.88102 25.1654 4.97248 25.1852 6.32625C25.1883 6.44847 25.239 6.56464 25.3266 6.64999C25.4141 6.73534 25.5316 6.78311 25.6538 6.78311C25.7761 6.78311 25.8935 6.73534 25.9811 6.64999C26.0686 6.56464 26.1194 6.44847 26.1225 6.32625C26.1422 4.97247 27.2333 3.88101 28.5871 3.86124C28.7093 3.85815 28.8255 3.80742 28.9109 3.71987C28.9962 3.63232 29.044 3.51488 29.044 3.39259C29.044 3.27031 28.9962 3.15287 28.9109 3.06532C28.8255 2.97777 28.7093 2.92704 28.5871 2.92395C27.2333 2.90417 26.1422 1.81314 26.1225 0.459376C26.12 0.335847 26.0689 0.218292 25.9802 0.132274C25.8915 0.0462552 25.7724 -0.00127221 25.6489 2.59091e-05ZM25.6535 2.12203C25.9583 2.65121 26.3951 3.08807 26.9243 3.39281C26.3951 3.69755 25.9582 4.134 25.6535 4.66316C25.3488 4.13404 24.9123 3.69754 24.3832 3.39281C24.9123 3.08808 25.3488 2.65118 25.6535 2.12203Z" fill={fill} />
<path d="M2.99998 7.08663C2.87809 7.08783 2.76145 7.13646 2.6748 7.22221C2.58816 7.30797 2.53833 7.4241 2.53588 7.54598C2.51905 8.68744 1.60029 9.60633 0.458857 9.623C0.336623 9.6261 0.220438 9.67684 0.135082 9.76439C0.0497256 9.85194 0.00195312 9.96938 0.00195312 10.0916C0.00195313 10.2139 0.0497256 10.3314 0.135082 10.4189C0.220438 10.5065 0.336623 10.5572 0.458857 10.5603C1.60029 10.577 2.5192 11.4963 2.53588 12.6378C2.539 12.76 2.58975 12.8761 2.6773 12.9615C2.76484 13.0468 2.88227 13.0946 3.00452 13.0946C3.12678 13.0946 3.2442 13.0468 3.33175 12.9615C3.4193 12.8761 3.47005 12.76 3.47317 12.6378C3.48978 11.4963 4.40916 10.577 5.55063 10.5603C5.67287 10.5572 5.78905 10.5065 5.87441 10.4189C5.95977 10.3314 6.00754 10.2139 6.00754 10.0916C6.00754 9.96938 5.95977 9.85194 5.87441 9.76439C5.78905 9.67684 5.67287 9.6261 5.55063 9.623C4.40916 9.60632 3.48985 8.68742 3.47317 7.54598C3.47069 7.42253 3.4196 7.30504 3.331 7.21904C3.24241 7.13303 3.12346 7.08545 2.99998 7.08663ZM3.00464 9.04081C3.26507 9.47671 3.61938 9.83139 4.05525 10.0919C3.61953 10.3523 3.26502 10.7068 3.00464 11.1425C2.74422 10.7067 2.38978 10.3523 1.954 10.0919C2.38992 9.83137 2.74418 9.47677 3.00464 9.04081Z" fill={fill} />
</svg>
);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,31 @@
interface MenuIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function MenuIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: MenuIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg
width={width}
height={height}
viewBox="0 0 35 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke={stroke}
strokeWidth={sw}
>
<path d="M7.35921 48.5323H1.46771V8.81016H23.2387C23.644 8.81016 23.9726 8.48154 23.9726 8.07627C23.9726 7.671 23.6441 7.34238 23.2387 7.34238H6.69524L12.6268 5.85957C13.02 5.76133 13.2591 5.36289 13.1608 4.96963C13.0625 4.57637 12.6638 4.33711 12.2708 4.43565L0.55589 7.36446C0.23587 7.44112 -0.0030947 7.74727 3.02976e-05 8.07637V49.2661C3.02976e-05 49.6714 0.328546 50 0.733917 50H7.35921C7.76448 50 8.0931 49.6715 8.0931 49.2661C8.0931 48.8607 7.76448 48.5323 7.35921 48.5323Z" fill={fill}/>
<path d="M33.7794 7.3423H30.8394V0.73361C30.8394 0.507634 30.7353 0.294255 30.5571 0.155192C30.379 0.0161299 30.1468 -0.0330888 29.9275 0.0216963L15.1163 3.72433C14.7231 3.82258 14.484 4.22101 14.5823 4.61427C14.6806 5.00744 15.0789 5.2465 15.4723 5.14826L29.3717 1.67365V7.3425H26.174C25.7688 7.3425 25.4401 7.67111 25.4401 8.07638C25.4401 8.48166 25.7688 8.81027 26.174 8.81027H33.0456V48.5323H10.2944C9.88916 48.5323 9.56055 48.861 9.56055 49.2662C9.56055 49.6715 9.88916 50.0001 10.2944 50.0001H33.7794C34.1847 50.0001 34.5133 49.6716 34.5133 49.2662V8.07619C34.5133 7.67091 34.1847 7.3423 33.7794 7.3423Z" fill={fill}/>
<path d="M12.4484 24.8246C13.0257 24.8246 13.5541 24.6929 14.005 24.4387L16.4546 26.8883L11.5418 31.8011C11.2552 32.0876 11.2552 32.5523 11.5418 32.8388C11.8284 33.1255 12.2931 33.1255 12.5796 32.8388L17.4924 27.9261L22.4052 32.8388C22.6918 33.1255 23.1565 33.1255 23.443 32.8388C23.7296 32.5523 23.7296 32.0876 23.443 31.8011L18.5302 26.8883L20.5533 24.8652C20.9797 25.116 21.4609 25.246 21.977 25.246C22.0218 25.246 22.067 25.245 22.1124 25.243C24.6817 25.132 27.4395 21.9364 27.7455 21.5724C28.0061 21.2623 27.9661 20.7998 27.6561 20.5388C27.346 20.278 26.8833 20.3179 26.6222 20.6277C25.9042 21.48 23.6825 23.7071 22.0479 23.7768C21.914 23.7822 21.7899 23.7711 21.6723 23.7462L25.7509 19.6676C26.0376 19.381 26.0376 18.9163 25.7509 18.6298C25.4644 18.3431 24.9997 18.3431 24.7131 18.6298L20.6342 22.7087C20.6093 22.591 20.5982 22.4667 20.604 22.3328C20.6737 20.6982 22.9008 18.4766 23.7528 17.7588C24.063 17.4979 24.1031 17.035 23.8423 16.7248C23.5815 16.4145 23.1188 16.3745 22.8084 16.6352C22.4444 16.9412 19.2487 19.6991 19.1377 22.2684C19.1132 22.8358 19.2425 23.3643 19.5152 23.8278L17.4925 25.8505L15.0428 23.4008C15.3926 22.7804 15.5099 22.0135 15.3728 21.1746C15.2133 20.1988 14.7287 19.2061 14.0079 18.3797C13.1142 17.3548 11.9956 16.7689 10.8579 16.7301C9.89761 16.6979 8.99751 17.0493 8.32544 17.7214C7.65337 18.3934 7.30132 19.2929 7.33413 20.2538C7.373 21.3915 7.95884 22.5101 8.98365 23.4038C9.81021 24.1246 10.8029 24.6093 11.7787 24.7687C12.0079 24.8061 12.2315 24.8246 12.4484 24.8246ZM8.80103 20.2037C8.78228 19.6536 8.98189 19.1405 9.36324 18.7592C9.72818 18.3943 10.2135 18.1958 10.7367 18.1958C10.7603 18.1958 10.7839 18.1962 10.8077 18.197C11.5345 18.2218 12.2781 18.6293 12.9015 19.3443C13.9969 20.6007 14.2912 22.2197 13.5574 22.9535C12.8234 23.6872 11.2044 23.393 9.9482 22.2976C9.23325 21.6741 8.82583 20.9305 8.80103 20.2037Z" fill={fill}/>
<path d="M24.7056 38.5016C25.1108 38.5016 25.4395 38.173 25.4395 37.7678C25.4395 37.3625 25.1109 37.0339 24.7056 37.0339H9.80615C9.40088 37.0339 9.07227 37.3625 9.07227 37.7678C9.07227 38.173 9.40088 38.5016 9.80615 38.5016H24.7056Z" fill={fill}/>
<path d="M12.4487 40.9263C12.0435 40.9263 11.7148 41.2549 11.7148 41.6602C11.7148 42.0654 12.0434 42.394 12.4487 42.394H22.0645C22.4697 42.394 22.7983 42.0655 22.7983 41.6602C22.7983 41.2549 22.4697 40.9263 22.0645 40.9263H12.4487Z" fill={fill}/>
</svg>
);
}

View File

@ -0,0 +1,31 @@
interface RestaurantIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function RestaurantIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0 }: RestaurantIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg
width={width}
height={height}
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke={stroke}
strokeWidth={sw}
>
<path d="M17.4874 21.1109L12.6474 15.6609C12.2074 15.1659 12.9574 14.5029 13.3954 14.9969L18.2444 20.4569C18.8074 21.1269 19.9324 21.1589 20.5374 20.5549C21.1934 19.9009 21.1444 18.8529 20.4544 18.2579L14.9654 13.3869C14.4734 12.9479 15.1314 12.1989 15.6294 12.6389L21.1124 17.5049C22.2354 18.4739 22.3094 20.1949 21.2454 21.2629C20.2064 22.2969 18.4564 22.2619 17.4874 21.1109Z" fill={fill}/>
<path d="M8.587 11.0909L7.887 10.2999C7.786 10.1839 7.656 10.1289 7.493 10.1369C5.411 10.2459 3.694 9.68293 2.391 8.44993C0.0650003 6.24793 0 2.87093 0 2.83793C0 1.14893 0.972 0.00793457 2.83 0.00793457C2.864 0.00793457 6.24 0.0729344 8.442 2.39893C9.675 3.70093 10.242 5.41593 10.129 7.49593C10.119 7.65894 10.177 7.79994 10.297 7.90994L11.258 8.76194C11.752 9.19993 11.091 9.94893 10.594 9.50993L9.628 8.65294C9.281 8.33594 9.102 7.89393 9.131 7.43693C9.229 5.64693 8.753 4.18194 7.716 3.08694C5.834 1.09693 2.86 1.00793 2.831 1.00793C1.559 1.00793 1.001 1.67693 1.001 2.83793C1.001 2.86693 1.09 5.84094 3.079 7.72394C4.174 8.76094 5.644 9.23894 7.433 9.13894C7.897 9.10194 8.334 9.29094 8.638 9.63994L9.336 10.4279C9.778 10.9279 9.023 11.5809 8.587 11.0909Z" fill={fill}/>
<path d="M16.7619 5.09092L20.9609 1.24092C21.4509 0.794923 22.1229 1.53192 21.6369 1.97792L17.4379 5.82792C16.9599 6.26792 16.2679 5.54492 16.7619 5.09092Z" fill={fill}/>
<path d="M18.1826 6.57392L22.0326 2.37392C22.4836 1.88792 23.2146 2.56292 22.7686 3.04992L18.9186 7.24992C18.4836 7.72392 17.7306 7.06692 18.1826 6.57392Z" fill={fill}/>
<path d="M4.56695 22.0079C3.15195 22.0079 2.00195 20.8579 2.00195 19.4429C2.00195 18.6919 2.32795 17.9839 2.89695 17.5009L13.801 7.81994C13.892 7.73894 13.95 7.62894 13.965 7.50894L14.203 5.60294C14.301 4.81894 14.717 4.09394 15.343 3.61194L19.893 0.111935C20.416 -0.293065 21.027 0.500936 20.502 0.904936L15.952 4.40494C15.536 4.72494 15.261 5.20693 15.195 5.72693L14.957 7.63094C14.913 7.98994 14.738 8.32294 14.465 8.56694L3.55295 18.2549C3.19995 18.5549 3.00195 18.9849 3.00195 19.4429C3.00195 20.3059 3.70395 21.0079 4.56695 21.0079C5.02395 21.0079 5.45395 20.8109 5.74695 20.4659L15.443 9.54494C15.685 9.27294 16.017 9.09794 16.379 9.05294L18.284 8.81494C18.804 8.74994 19.286 8.47394 19.607 8.05794L23.106 3.50894C23.511 2.98494 24.301 3.59394 23.899 4.11794L20.4 8.66794C19.918 9.29394 19.192 9.70894 18.409 9.80794L16.504 10.0459C16.384 10.0609 16.273 10.1199 16.192 10.2109L6.50195 21.1209C6.02595 21.6819 5.31795 22.0079 4.56695 22.0079Z" fill={fill}/>
</svg>
);
}

View File

@ -0,0 +1,38 @@
interface ServiceIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function ServiceIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: ServiceIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg
width={width}
height={height}
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke={stroke}
strokeWidth={sw}
>
<path d="M7.47172 1.66663C5.07816 1.66663 3.13086 3.61387 3.13086 6.00749C3.13086 8.40104 5.07816 10.3483 7.47172 10.3483C9.86527 10.3483 11.8126 8.4011 11.8126 6.00749C11.8125 3.61387 9.86527 1.66663 7.47172 1.66663ZM7.47172 9.38366C5.61014 9.38366 4.09549 7.86913 4.09549 6.00743C4.09549 4.14573 5.61008 2.6312 7.47172 2.6312C9.33336 2.6312 10.8479 4.14573 10.8479 6.00743C10.8479 7.86913 9.3333 9.38366 7.47172 9.38366Z" fill={fill}/>
<path d="M7.41268 11.2487L4.61523 10.455V14.4204L7.41268 13.6818L10.2101 14.4204V10.455L7.41268 11.2487ZM9.24549 13.1681L7.41268 12.6842L5.57986 13.1681V11.7315L7.41268 12.2514L9.24549 11.7315V13.1681Z" fill={fill} />
<path d="M20.6916 12.5262L17.6369 15.5807L14.7829 12.7267C14.2059 12.1498 13.4389 11.8321 12.6229 11.8321C12.6229 11.8321 12.6224 11.8321 12.6223 11.8321L11.2704 11.8324L11.2706 12.797L12.6224 12.7967H12.6229C13.1812 12.7967 13.7061 13.0141 14.1008 13.4088L17.6368 16.9448L21.0909 13.4907H23.1149L17.5888 19.6443L13.1995 15.6705V29.0353H3.55324V23.4137H4.50721V27.851H10.3057V23.4137H12.7173V19.6516H10.3057V19.1693H4.50721V19.6516H3.5533V16.7578H2.58867V20.6163H4.50721V22.4491H3.5533H3.07102H0.964629V14.8888C0.964629 13.7366 1.90201 12.799 3.0542 12.7987L3.05397 11.8341C1.36998 11.8345 0 13.2048 0 14.8888V23.4137H2.58873V30H14.1643V17.8451L17.6596 21.0095L25.2779 12.5262H20.6916ZM10.3057 20.6163H11.7527V22.4491H10.3057V20.6163ZM5.47184 20.134H9.34107V26.8864H5.47184V20.134Z" fill={fill} />
<path d="M27.3003 11.0022C27.0683 8.25897 24.8769 6.06738 22.1388 5.84432V5.02148H21.1742V5.84818C18.4562 6.09047 16.2864 8.27338 16.0556 11.0022H15.2578V11.9668H16.0349H27.321H28.0553V11.0022H27.3003ZM17.0239 11.0022C17.2655 8.63941 19.2605 6.79002 21.6779 6.79002C24.0952 6.79002 26.0903 8.63947 26.3319 11.0022H17.0239Z" fill={fill} />
<path d="M18.248 10.4877H19.2127C19.2127 9.43752 20.0637 8.58322 21.1098 8.58322V7.61859C19.5318 7.61859 18.248 8.90566 18.248 10.4877Z" fill={fill} />
<path d="M27.6697 2.0257H26.7051V2.99032H27.6697V2.0257Z" fill={fill} />
<path d="M27.6697 5.88422H26.7051V6.7524H27.6697V5.88422Z" fill={fill} />
<path d="M27.6697 3.95496H26.7051V4.91958H27.6697V3.95496Z" fill={fill} />
<path d="M25.8357 2.99033H24.8711V3.95495H25.8357V2.99033Z" fill={fill} />
<path d="M25.8357 1.06107H24.8711V2.02569H25.8357V1.06107Z" fill={fill} />
<path d="M23.908 1.92926H22.9434V2.89389H23.908V1.92926Z" fill={fill} />
<path d="M23.908 0H22.9434V0.964629H23.908V0Z" fill={fill} />
</svg>
);
}

View File

@ -0,0 +1,10 @@
import { useEffect } from 'preact/hooks'
import { useLocation } from 'react-router-dom'
export default function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return null
}

View File

@ -27,6 +27,7 @@ const POST_CREATE_LOCATION = GET_LIST_LOCATIONS_URI;
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`;
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`;
const POST_REVIEW_IMAGES_URI = `${BASE_URL}/review/location/images`;
const GET_CURRENT_USER_REVIEW_LOCATION_URI = `${BASE_URL}/user/review/location`;
export {
@ -51,6 +52,7 @@ export {
PATCH_USER_AVATAR,
PATCH_USER_INFO,
POST_REVIEW_LOCATION_URI,
POST_REVIEW_IMAGES_URI,
POST_CREATE_LOCATION,
POST_NEWS_EVENTS_URI,
}

View File

@ -1,3 +1,3 @@
export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png';
export const DEFAULT_AVATAR_IMG = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQPdKbLIfst1MqPxPK2lZZ7odhmD1P1aU3ReQ&s';
export const DEFAULT_LOCATION_THUMBNAIL_IMG = 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg';

View File

@ -1,9 +1,10 @@
export type LocationInfo = {
id: number,
name: string,
thumbnail: string | null,
regency_name: String,
province_name: String,
thumbnail: string,
regency_name: string,
province_name: string,
location_type: string,
critic_score: number,
critic_count: number,
user_score: number,

View File

@ -21,7 +21,7 @@ function AddLocation() {
name: '',
address: '',
google_maps_link: '',
location_type: LocationType.Beach,
location_type: LocationType.Recreation,
regency: emptyRegency(),
thumbnails: [],
})
@ -103,7 +103,7 @@ function AddLocation() {
const files = Array.from(event.files as ArrayLike<File>);
const result = files.filter((x) => {
if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg") {
if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg" || x.type == "image/webp") {
return true
}
return false

View File

@ -3,24 +3,25 @@ import { useEffect, useState } from "preact/hooks";
import { getListTopLocationsService } from "../../services";
import { DefaultSeparator } from "../../components";
import './style.css';
import { useClick, useFloating, useInteractions } from "@floating-ui/react";
import { UserStar, User, Map, HandPlatter, ClockCheck } from 'lucide-react';
import { Link } from "react-router-dom";
interface TopLocation {
row_number: Number,
id: Number,
name: String,
thumbnail: string | null,
address: String,
row_number: number,
id: number,
name: string,
location_type: string,
thumbnail: string,
address: string,
google_maps_link: string,
regency_name: string,
critic_score: Number,
critic_count: Number,
user_score: Number,
user_count: Number,
critic_bayes: Number,
user_bayes: Number,
avg_bayes: Number,
critic_score: number,
critic_count: number,
user_score: number,
user_count: number,
critic_bayes: number,
user_bayes: number,
avg_bayes: number,
}
const REGIONS = [
@ -47,8 +48,14 @@ const MIN_REVIEWS = [
11
]
function getRatingColorClass(score: number): string {
if (score < 40) return 'text-rating-red';
if (score < 70) return 'text-rating-yellow';
return 'text-rating-green';
}
function BestLocation() {
const [page, _setPage] = useState<number>(1);
const [page, _setPage] = useState<number>(1);
const [topLocations, setTopLocations] = useState<Array<TopLocation>>([])
const [pageState, setPageState] = useState({
filterScoreType: 'all',
@ -132,7 +139,7 @@ function BestLocation() {
<div className={'flex flex-row pt-10 pb-10'}>
<div className={'mr-5'} style={{ flex: 1 }}>
{topLocations.map(x => (
<>
<div className={'mb-3'}>
{/* UNCOMMENT....IF THERES A PURPOSE FOR RIGHT THREE DOTS */}
{/* <div style={{ float: 'right', cursor: 'pointer' }}>
<a className={'text-xl'}>
@ -140,48 +147,74 @@ function BestLocation() {
</a>
</div> */}
<div className={'mb-2 best-locations-title'}>
<Link className={'text-xl'} to={`/location/${x.id}`}>{x.row_number}.{x.name}</Link>
<Link className={'text-xl'} to={`/location/${x.id}`}>{x.row_number}. {x.name}, {x.regency_name}</Link>
</div>
<div style={{ maxWidth: 200, maxHeight: 200, margin: '0 30px 30px 10px', float: 'left' }}>
<Link to={`/location/${x.id}`} >
<img src={x.thumbnail ? x.thumbnail : ""} loading={'lazy'} style={{ width: '100%', objectFit: 'cover', height: '100%', aspectRatio: '1/1' }} />
<div style={{ maxWidth: 215, margin: '0 30px 0px 10px', float: 'left' }}>
<Link to={`/location/${x.id}`}>
<img
src={x.thumbnail}
loading={'lazy'}
style={{ width: '100%', objectFit: 'cover', height: '100%', aspectRatio: '1/1' }}
onError={(e) => {
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
e.currentTarget.onerror = null;
}}
/>
</Link>
{/* <div className={'md:hidden text-xs mt-2 flex flex-row flex-wrap gap-2'}>
{x.location_type === 'culinary' &&
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><HandPlatter className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}> Menu </span></a>
}
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><ClockCheck className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}> 08.00 - 22.00 </span></a>
</div> */}
</div>
<div className={'text-md font-bold'}>{x.regency_name}</div>
<div className={'text-xs mb-2'}>{x.address}</div>
<div>$$$ (IDR 1000-12000)</div>
<a href={x.google_maps_link} target={'_'}><div className={'text-sm mt-2 items-center'}><svg style={{ display: 'inline-block', marginBottom: 3 }} xmlns="http://www.w3.org/2000/svg" height="12" fill={'white'} viewBox="0 -960 960 960" width="12 "><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z" /></svg>Maps Location</div></a>
<div className={'mt-4'}>
<div className={'text-xs bg-secondary'} style={{ width: 160, display: 'inline-block', borderRadius: 5 }}>
<div className={'text-center p-1 bg-tertiary text-primary'} style={{ borderTopRightRadius: 5, borderTopLeftRadius: 5 }}>CRITICS SCORE</div>
<div className={"flex flex-row items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<p className={`text-xl text-center ${x.critic_score !== 0 ? 'font-bold' : ''}`}>{x.critic_score !== 0 ? Number(x.critic_score) / Number(x.critic_count) * 10 : "N/A"}</p>
<div className={"mt-1"} style={{ height: 4, width: 40, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${x.critic_count !== 0 ? Number(x.critic_score) / Number(x.critic_count) * 10 : 0}%`, backgroundColor: 'green' }} />
</div>
{/* <div className={'text-md font-bold'}>{x.regency_name}</div> */}
<div className={'text-xs md:text-sm h-[2rem] md:h-[2.625rem] underline'}><a className={'flex flex-row items-start gap-1'} href={x.google_maps_link} target="_"><Map className="w-4 h-4 flex-shrink-0 mt-0.5" /><span className="line-clamp-2">{x.address}</span></a></div>
<div className={'text-xs mt-2 flex flex-row flex-wrap gap-2'}>
{x.location_type === 'culinary' &&
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><HandPlatter className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Menu</span></a>
}
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><ClockCheck className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Open</span></a>
</div>
{/* <div className={'md:block text-xs md:text-sm mb-2'}><a className={'flex flex-row items-start gap-1'} href={x.google_maps_link} target="_"><HandPlatter className="w-4 h-4 flex-shrink-0 mt-0.5" /><span className="line-clamp-2"> - </span></a></div>
<div className={'md:block text-xs md:text-sm mb-2'}><a className={'flex flex-row items-start gap-1'} href={x.google_maps_link} target="_"><ClockCheck className="w-4 h-4 flex-shrink-0 mt-0.5" /><span className="line-clamp-2"> - </span></a></div> */}
{/* <div>$$$ (IDR 1000-12000)</div> */}
{/* <a href={} target={'_'}><div className={'text-sm mt-2 items-center'}><svg style={{ display: 'inline-block', marginBottom: 3 }} xmlns="http://www.w3.org/2000/svg" height="12" fill={'white'} viewBox="0 -960 960 960" width="12 "><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z" /></svg>Maps Location</div></a> */}
<div className={'mt-2 md:mt-4 flex flex-col md:flex-row gap-1.5 md:gap-3'}>
<div className={'border border-gray-600 w-full md:w-auto rounded-xl p-1.5 md:p-4'} style={{ maxWidth: 280 }}>
<div className={"flex flex-row items-center gap-1.5 md:gap-4"}>
<div className={'flex-shrink-0'}>
<UserStar className="w-6 h-6 md:w-8 md:h-8" strokeWidth={2} />
</div>
<div className={'flex-1'}>
<div className={'text-xxs md:text-sm text-gray-300 mb-0.5 md:mb-1'}>Critic Score</div>
<div className={`text-base md:text-2xl font-bold mb-0.5 md:mb-1 ${x.critic_score !== 0 ? getRatingColorClass(Math.round(Number(x.critic_score) / Number(x.critic_count) * 10)) : ''}`}>
{x.critic_score !== 0 ? Math.round(Number(x.critic_score) / Number(x.critic_count) * 10) : "N/A"}
</div>
<div className={'text-xxs md:text-xs text-gray-400'}>{x.critic_count} reviews</div>
</div>
<p className={'text-xs users-score'}>{x.critic_count} reviews</p>
</div>
</div>
<div className={'text-xs bg-secondary ml-3'} style={{ width: 160, display: 'inline-block', borderRadius: 5 }}>
<div className={'text-center p-1 bg-tertiary text-primary'} style={{ borderTopLeftRadius: 5, borderTopRightRadius: 5 }}>USERS SCORE</div>
<div className={"flex flex-row items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<p className={`text-xl text-center ${x.user_score !== 0 ? 'font-bold' : ''}`}>{x.user_score !== 0 ? x.user_score : "N/A"}</p>
<div className={"mt-1"} style={{ height: 4, width: 40, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${x.user_score !== 0 ? x.user_score : 0}%`, backgroundColor: 'green' }} />
</div>
<div className={'border border-gray-600 w-full md:w-auto rounded-xl p-1.5 md:p-4'}>
<div className={"flex flex-row items-center gap-1.5 md:gap-4"}>
<div className={'flex-shrink-0'}>
<User className="w-6 h-6 md:w-8 md:h-8" strokeWidth={2} />
</div>
<div className={'flex-1'}>
<div className={'text-xxs md:text-sm text-gray-300 mb-0.5 md:mb-1'}>User Score</div>
<div className={`text-base md:text-2xl font-bold mb-0.5 md:mb-1 ${x.user_score !== 0 ? getRatingColorClass(Math.round(x.user_score)) : ''}`}>
{x.user_score !== 0 ? Math.round(x.user_score) : "N/A"}
</div>
<div className={'text-xxs md:text-xs text-gray-400'}>{x.user_count} reviews</div>
</div>
<p className={'text-xs users-score'}>{x.user_count} reviews</p>
</div>
</div>
</div>
<div style={{ clear: 'both' }} />
</>
</div>
))}
</div>
<div className={'p-4 bg-secondary'} style={{ minWidth: 300 }}>
{/* <div className={'p-4 bg-secondary'} style={{ minWidth: 300 }}>
<div className={'h-30 bg-primary p-4 right-filter'}>
{REVIEWERS_TYPE.map((x, idx) => (
<a
@ -193,7 +226,7 @@ function BestLocation() {
</a>
))}
</div>
</div>
</div> */}
</div>
</section>
</div>

View File

@ -1,160 +1,21 @@
import { useEffect, useState } from "preact/hooks";
import { CheckboxInput, DefaultSeparator, FilterButton, LocationCard, SpinnerLoading } from "../../components";
import { getListRecentLocationsRatingsService, getRegenciesService, } from "../../services";
import { LocationInfo, Regency } from "../../domains";
import { LocationType } from "../../types/common";
import { enumKeys } from "../../utils";
import { useNavigate } from "react-router-dom";
import { FloatFilter } from "../../components/Filter/FloatFilter";
import { useDiscovery } from "./useDiscovery";
function Discovery() {
interface DiscoveryState {
filterQ: string,
regencies: any[],
searchRegencies: any[],
locations: LocationInfo[],
locationType: Array<{value: string, isSelected?: boolean}>
}
const [data, setData] = useState<DiscoveryState>({
filterQ: '',
regencies: [],
searchRegencies: [],
locations: [],
locationType: []
});
const [isFloatFilterOpen, setFloatFilterOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
loadData()
}, [])
async function loadData() {
setIsLoading(true)
try {
await Promise.all([
getLocationType(),
getRecentLocations(),
getRegion()
])
} finally {
setIsLoading(false)
}
}
async function getRegion() {
try {
const res = await getRegenciesService();
setData((prevState) => ({ ...prevState, regencies: res.data, searchRegencies: res.data }))
} catch (err) {
alert("Something went wrong when fetch regions / regencies")
}
}
async function getRecentLocations() {
try {
const locations = await getListRecentLocationsRatingsService(15, 1)
setData((prevState) => ({ ...prevState, locations: locations.data }))
} catch (error) {
console.log(error)
}
}
function onCheckedRegencyFilter(_val: any, id: number) {
// const value = val as HTMLInputElement;
const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[];
const regencyIdx = dataRegencies.findIndex(x => x.id == id);
dataRegencies[regencyIdx].isSelected = !dataRegencies[regencyIdx].isSelected
setData({
...data,
regencies: dataRegencies,
searchRegencies: dataRegencies
})
}
function onClearFilter() {
const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[];
const regencies = dataRegencies.map(x => ({ ...x, isSelected: false }))
setData({
...data,
regencies: regencies,
searchRegencies: regencies
})
}
function onInputChange(search: string) {
const dataRegencies = data.regencies as (Regency & { isSelected?: boolean })[];
// console.log(dataRegencies.filter(x => x.regency_name.toLowerCase().includes(search)))
setData({
...data,
searchRegencies: dataRegencies.filter(x => x.regency_name.toLowerCase().includes(search))
})
}
function onClickedTypeLocations(idx: number) {
const locType = data.locationType
locType[idx].isSelected = !locType[idx].isSelected
setData({
...data,
locationType: locType
})
}
function onApplyFilter() {
const dataRegencies = data.regencies as (Regency & { isSelected?: boolean})[];
const selectedRegencies = dataRegencies.filter(x => x.isSelected)
const selectedLocType = data.locationType.filter(x => x.isSelected)
let regenciesQ = "";
let locTypeQ = ""
if(selectedRegencies.length > 0) {
regenciesQ = selectedRegencies.map(x => x.id).join(" OR ").replace(/\s+/g, '%20')
}
if(selectedLocType.length > 0) {
const onJoin = selectedRegencies.length > 0 ? " AND " : " OR "
locTypeQ = selectedLocType.map(x => x.value).join(onJoin).replace(/\s+/g, '%20')
if(selectedRegencies.length > 0) locTypeQ = `%20AND%20${locTypeQ}`
}
const searchQ = `${regenciesQ}${locTypeQ}`
}
function getLocationType() {
const type: Array<{value: string, isSelected?: boolean}> = []
for (const lt of enumKeys(LocationType)) {
type.push({value: LocationType[lt]});
}
setData((prevState) => ({ ...prevState, locationType: type }))
}
function onClickLocation(id: number) {
navigate(`/location/${id}`)
}
function onDeleteByCityFilter(value: any) {
const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[];
const regencyIdx = dataRegencies.findIndex(x => x.id == value.id);
dataRegencies[regencyIdx].isSelected = !dataRegencies[regencyIdx].isSelected
setData({
...data,
regencies: dataRegencies,
searchRegencies: dataRegencies
})
}
const {
data,
isFloatFilterOpen,
setFloatFilterOpen,
isLoading,
onCheckedRegencyFilter,
onClearFilter,
onInputChange,
onClickedTypeLocations,
onApplyFilter,
onClickLocation,
onDeleteByCityFilter
} = useDiscovery();
if (isLoading) {
return (
@ -228,7 +89,7 @@ function Discovery() {
</section>
{/* FILTER END */}
<div>
{data.locations.map(x => (
{data.locations.map((x: any) => (
<LocationCard
containerClass="p-[10px_1%_15px] inline-block m-[0_0_15px] align-top w-[20%] max-[1300px]:w-[24.3%] max-[1135px]:w-[25%] max-[1100px]:w-[33%] max-[920px]:w-[33%] max-[625px]:w-[50%]"
onCardClick={onClickLocation}

View File

@ -0,0 +1,166 @@
import { useEffect, useState } from "preact/hooks";
import { useNavigate } from "react-router-dom";
import { useRecentLocationsRatings } from "../../services/locations";
import { useRegencies } from "../../services/regions";
import { Regency } from "../../domains";
import { LocationType } from "../../types/common";
import { enumKeys } from "../../utils";
interface DiscoveryState {
filterQ: string;
regencies: (Regency & { isSelected?: boolean })[];
searchRegencies: (Regency & { isSelected?: boolean })[];
locationType: Array<{ value: string; isSelected?: boolean }>;
}
export function useDiscovery() {
const [data, setData] = useState<DiscoveryState>({
filterQ: '',
regencies: [],
searchRegencies: [],
locationType: []
});
const [isFloatFilterOpen, setFloatFilterOpen] = useState(false);
const navigate = useNavigate();
const { data: locationsData, isLoading: isLoadingLocations, error: locationsError } = useRecentLocationsRatings(15, 1);
const { data: regenciesData, isLoading: isLoadingRegencies, error: regenciesError } = useRegencies();
const isLoading = isLoadingLocations || isLoadingRegencies;
useEffect(() => {
if (regenciesData) {
const regenciesWithSelection = regenciesData.map((r: Regency) => ({ ...r, isSelected: false }));
setData((prevState) => ({
...prevState,
regencies: regenciesWithSelection,
searchRegencies: regenciesWithSelection
}));
}
}, [regenciesData]);
useEffect(() => {
const type: Array<{ value: string; isSelected?: boolean }> = [];
for (const lt of enumKeys(LocationType)) {
type.push({ value: LocationType[lt], isSelected: false });
}
setData((prevState) => ({ ...prevState, locationType: type }));
}, []);
useEffect(() => {
if (regenciesError) {
alert("Something went wrong when fetching regions / regencies");
}
}, [regenciesError]);
useEffect(() => {
if (locationsError) {
console.error("Error fetching locations:", locationsError);
}
}, [locationsError]);
function onCheckedRegencyFilter(_val: any, id: number) {
const dataRegencies = [...data.searchRegencies];
const regencyIdx = dataRegencies.findIndex(x => x.id == id);
if (regencyIdx !== -1) {
dataRegencies[regencyIdx] = {
...dataRegencies[regencyIdx],
isSelected: !dataRegencies[regencyIdx].isSelected
};
setData({
...data,
regencies: dataRegencies,
searchRegencies: dataRegencies
});
}
}
function onClearFilter() {
const regencies = data.searchRegencies.map(x => ({ ...x, isSelected: false }));
setData({
...data,
regencies: regencies,
searchRegencies: regencies
});
}
function onInputChange(search: string) {
const searchLower = search.toLowerCase();
setData({
...data,
searchRegencies: data.regencies.filter(x => x.regency_name.toLowerCase().includes(searchLower))
});
}
function onClickedTypeLocations(idx: number) {
const locType = [...data.locationType];
if (idx >= 0 && idx < locType.length) {
locType[idx] = {
...locType[idx],
isSelected: !locType[idx].isSelected
};
setData({
...data,
locationType: locType
});
}
}
function onApplyFilter() {
const selectedRegencies = data.regencies.filter(x => x.isSelected);
const selectedLocType = data.locationType.filter(x => x.isSelected);
let regenciesQ = "";
let locTypeQ = "";
if (selectedRegencies.length > 0) {
regenciesQ = selectedRegencies.map(x => x.id).join(" OR ").replace(/\s+/g, '%20');
}
if (selectedLocType.length > 0) {
const onJoin = selectedRegencies.length > 0 ? " AND " : " OR ";
locTypeQ = selectedLocType.map(x => x.value).join(onJoin).replace(/\s+/g, '%20');
if (selectedRegencies.length > 0) locTypeQ = `%20AND%20${locTypeQ}`;
}
const searchQ = `${regenciesQ}${locTypeQ}`;
// TODO: Implement search with searchQ
console.log("Search query:", searchQ);
}
function onClickLocation(id: number) {
navigate(`/location/${id}`);
}
function onDeleteByCityFilter(value: any) {
const dataRegencies = [...data.searchRegencies];
const regencyIdx = dataRegencies.findIndex(x => x.id == value.id);
if (regencyIdx !== -1) {
dataRegencies[regencyIdx] = {
...dataRegencies[regencyIdx],
isSelected: !dataRegencies[regencyIdx].isSelected
};
setData({
...data,
regencies: dataRegencies,
searchRegencies: dataRegencies
});
}
}
return {
data: {
...data,
locations: locationsData || []
},
isFloatFilterOpen,
setFloatFilterOpen,
isLoading,
onCheckedRegencyFilter,
onClearFilter,
onInputChange,
onClickedTypeLocations,
onApplyFilter,
onClickLocation,
onDeleteByCityFilter
};
}

View File

@ -1,67 +1,16 @@
import { LocationCard, SeparatorWithAnchor } from '../../components';
// import news from '../../datas/recent_news_event.json';
import popular from '../../datas/popular.json';
// import popular_user_review from '../../datas/popular_user_reviews.json';
import './style.css';
import { useEffect, useState } from 'preact/hooks';
import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services';
import { useNavigate } from 'react-router-dom';
import { LocationInfo } from '../../domains/LocationInfo';
type News = {
header: string,
thumbnail: string,
link: string,
comments_count: Number
likes_count: Number
}
import { useHome } from './useHome';
import { User } from 'lucide-react';
function Home() {
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>([])
const [topCriticsLocations, setTopCriticsLocations] = useState<Array<LocationInfo>>([])
const [topUsersLocations, setTopUsersLocations] = useState<Array<LocationInfo>>([])
// const [isLoading, setIsLoading] = useState<boolean>(true)
const navigate = useNavigate()
async function getRecentLocations() {
try {
const locations = await getListRecentLocationsRatingsService(12, 1)
setRecentLocations(locations.data)
// setIsLoading(false)
} catch (error) {
console.log(error)
}
}
async function getCrititsBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0 })
setTopCriticsLocations(res.data)
} catch (err) {
console.log(err)
}
}
async function getUsersBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0 })
setTopUsersLocations(res.data)
} catch (err) {
console.log(err)
}
}
function onNavigateToDetail(id: Number,) {
navigate(`/location/${id}`)
}
useEffect(() => {
getRecentLocations()
getCrititsBestLocations()
getUsersBestLocations()
}, [])
const {
recentLocations,
topCriticsLocations,
topUsersLocations,
onNavigateToDetail,
} = useHome();
return (
<div className="content main-content mt-3">
@ -173,7 +122,7 @@ function Home() {
<section className={"mt-10 mb-10"}>
<div class={"grid grid-cols-4 lg:gap-12"}>
<div className={'col-span-4 lg:col-span-2 trending-section'}>
<SeparatorWithAnchor pageLink='#' pageName={"Trending Now"} secondLink='#' />
<SeparatorWithAnchor pageLink='#' pageName={"trending this month"}/>
<div className={'grid grid-cols-4 md:grid-cols-3'}>
{popular.data.map((x) => (
<div className={"m-2 text-sm col-span-2 md:col-span-1"}>
@ -190,6 +139,7 @@ function Home() {
</div >
<p className={"location-title"}>{x.name}</p>
<p className={"text-xs location-title"}>{x.location}</p>
<p className={"text-xs location-title"}>1.2k users visit this month</p>
<div>
</div>
@ -198,23 +148,30 @@ function Home() {
</div>
</div>
<div className={'col-span-2 lg:col-span-1'}>
<SeparatorWithAnchor pageLink='#' pageName={"Critic's Best"} />
<SeparatorWithAnchor pageLink='#' pageName={"2026 Critic's Best"} />
{topCriticsLocations.map((x) => (
<div className={"pt-2 text-sm top-location-container"}>
<div className={'mr-2 critics-users-image'}>
<img
src={x.thumbnail ? x.thumbnail : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
src={x.thumbnail}
loading={'lazy'}
style={{ height: '100%', width: '100%', borderRadius: 3 }}
onError={(e) => {
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
e.currentTarget.onerror = null;
}}
/>
</div>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
<p className={'text-xs ml-2'}>{x.critic_score} <span className={'text-gray'}>({x.critic_count})</span></p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${x.critic_score}%`, backgroundColor: 'green' }} />
<div className={'flex items-center gam-1 mt-2'}>
<div>
<p className={'text-md text-center'}>{x.critic_score}</p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${x.critic_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<span className={'ml-2 text-gray text-sm flex items-center gap-1'}>({x.critic_count} <User size={16} />)</span>
</div>
<div style={{ clear: 'both' }} />
</div>
@ -222,22 +179,30 @@ function Home() {
}
</div>
<div className={'col-span-2 lg:col-span-1'}>
<SeparatorWithAnchor pageLink='#' pageName={"User's Best"} />
<SeparatorWithAnchor pageLink='#' pageName={"2026 User's Best"} />
{topUsersLocations.map((x) => (
<div className={"pt-2 text-sm top-location-container"}>
<div className={'mr-2 critics-users-image'}>
<img
src={x.thumbnail ? x.thumbnail : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
src={x.thumbnail}
loading={'lazy'}
style={{ height: '100%', width: '100%', borderRadius: 3 }}
onError={(e) => {
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
e.currentTarget.onerror = null;
}}
/>
</div>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
<p className={'text-xs ml-2'}>{x.user_score} <span className={'text-xs text-gray'}>({x.user_count})</span></p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${x.user_score}%`, backgroundColor: 'green' }} />
<div className={'flex items-center gap-1 mt-2'}>
<div>
<p className={'text-sm text-center'}>{x.user_score}</p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${x.user_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<span className={'ml-2 text-gray text-sm flex items-center gap-1'}>({x.user_count} <User size={16} /> )</span>
</div>
<div style={{ clear: 'both' }} />
</div>

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'preact/hooks';
import { useNavigate } from 'react-router-dom';
import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services';
import { LocationInfo } from '../../domains/LocationInfo';
export const useHome = () => {
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>([]);
const [topCriticsLocations, setTopCriticsLocations] = useState<Array<LocationInfo>>([]);
const [topUsersLocations, setTopUsersLocations] = useState<Array<LocationInfo>>([]);
// const [isLoading, setIsLoading] = useState<boolean>(true)
const navigate = useNavigate();
async function getRecentLocations() {
try {
const locations = await getListRecentLocationsRatingsService(12, 1);
setRecentLocations(locations.data);
// setIsLoading(false)
} catch (error) {
console.log(error);
}
}
async function getCrititsBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0 });
setTopCriticsLocations(res.data);
} catch (err) {
console.log(err);
}
}
async function getUsersBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0 });
setTopUsersLocations(res.data);
} catch (err) {
console.log(err);
}
}
function onNavigateToDetail(id: Number) {
navigate(`/location/${id}`);
}
useEffect(() => {
getRecentLocations();
getCrititsBestLocations();
getUsersBestLocations();
}, []);
return {
recentLocations,
topCriticsLocations,
topUsersLocations,
onNavigateToDetail,
};
};

View File

@ -10,17 +10,20 @@ import {
emptyLocationResponse,
CurrentUserLocationReviews,
} from './types';
import { AxiosError } from 'axios';
import { handleAxiosError, useAutosizeTextArea } from '../../utils';
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
import { handleApiError, useAutosizeTextArea } from '../../utils';
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components';
import RatingsCard from '../../components/Card/RatingsCard';
import { useSelector } from 'react-redux';
import { UserRootState } from '../../store/type';
import { DEFAULT_AVATAR_IMG } from '../../constants/default';
import { IHttpResponse } from '../../types/common';
import { ImagePlus } from 'lucide-react'
import { ImagePlus, Globe, MapPin, Smile, Heart, MessageCircle } from 'lucide-react'
import ReactTextareaAutosize from 'react-textarea-autosize';
import { MenuIcon } from '../../../src/components/Icons/MenuIcon';
import ScheduleCard from '../../components/Card/ScheduleCard';
import FacilitiesCard from '../../components/Card/FacilitiesCard';
const SORT_TYPE = [
'highest rated',
@ -46,7 +49,41 @@ function LocationDetail() {
const [reviewValue, setReviewValue] = useState({
review_textArea: '',
score_input: '',
title: '',
rasa: '',
suasana: '',
pelayanan: '',
kebersihan: '',
})
const [uploadedImages, setUploadedImages] = useState<{ file: File; preview: string }[]>([])
const [uploadLightboxOpen, setUploadLightboxOpen] = useState(false)
const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0)
const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false)
const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0)
const imageInputRef = useRef<HTMLInputElement>(null)
function handleImageSelect(e: ChangeEvent<HTMLInputElement>) {
const input = e.target as HTMLInputElement
if (!input.files) return
const newFiles = Array.from(input.files).map(file => ({
file,
preview: URL.createObjectURL(file),
}))
setUploadedImages(prev => [...prev, ...newFiles])
input.value = ''
}
function removeImage(index: number) {
setUploadedImages(prev => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
function openUploadLightbox(index: number) {
setUploadLightboxIndex(index)
setUploadLightboxOpen(true)
}
const [isLoading, setIsLoading] = useState<boolean>(true)
const [currentIndex, setCurrentIndex] = useState(0);
const currentImage = locationImages?.images[currentIndex]?.src || locationDetail.detail.thumbnail || "";
@ -69,11 +106,11 @@ function LocationDetail() {
}
})
} catch (error) {
let err = error as AxiosError;
if (err.response?.status == 404) {
const err = error as IHttpResponse;
if (err.status == 404) {
navigate("/")
}
alert(error)
alert(handleApiError(error))
}
}
@ -158,10 +195,22 @@ function LocationDetail() {
submitted_by: Number(user.id),
is_from_critic: user.is_critics,
comments: reviewValue.review_textArea,
title: reviewValue.title,
})
if (uploadedImages.length > 0) {
try {
await postReviewImages(data.id, uploadedImages.map(img => img.file))
} catch (imgErr) {
console.log('Image upload failed:', imgErr)
alert('Review posted, but images failed to upload. You can try again.')
}
uploadedImages.forEach(img => URL.revokeObjectURL(img.preview))
setUploadedImages([])
}
setPageState({ ...pageState, enable_post: false, on_submit_loading: false })
setReviewValue({ review_textArea: '', score_input: '' })
setReviewValue({ review_textArea: '', score_input: '', title: '', rasa: '', suasana: '', pelayanan: '', kebersihan: '' })
setCurrentUserReview({
id: data.id,
comments: data.comments,
@ -175,10 +224,8 @@ function LocationDetail() {
})
setUpdatePage(true)
} catch (error) {
let err = error as AxiosError;
console.log(err)
const str = handleAxiosError(err)
alert(str)
console.log(error)
alert(handleApiError(error))
setPageState({ ...pageState, on_submit_loading: false })
}
@ -212,10 +259,11 @@ function LocationDetail() {
</div>
</section>
<section className="pb-5 border-b border-[#38444d]"name={'LOCATION HEADER'}>
<section className="pb-5 border-b border-[#38444d]" name={'LOCATION HEADER'}>
<div>
<div className="font-bold mt-5 text-2xl">
<h1>{locationDetail?.detail.name}</h1>
<p className={'underline flex'}><MapPin /> {locationDetail?.detail.address}</p>
</div>
{/* {isLoading ?
<div className="mt-3 w-[250px] h-[250px] bg-gray float-left" />
@ -245,144 +293,198 @@ function LocationDetail() {
</a>
</div>
} */}
{isLoading ? (
<div className="mt-3 w-[250px] h-[250px] bg-gray float-left" />
) : (
<div className="inline-block w-full max-w-[650px]">
<div className="mt-3 relative group">
{/* Main image display */}
<div className="relative w-full h-[360px] max-[768px]:h-[240px] border-[#38444d] border-[1px] rounded-lg">
<img
src={currentImage}
alt=""
className="w-full h-full object-cover block cursor-zoom-in rounded-lg"
onClick={() => setLightboxOpen(true)}
/>
{locationImages?.images.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex((prev) =>
prev === 0 ? locationImages.images.length - 1 : prev - 1
);
}}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 text-white p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex((prev) =>
prev === locationImages.images.length - 1 ? 0 : prev + 1
);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 text-white p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</>
)}
{/* Total images badge */}
{locationImages?.images.length > 1 && (
<div className="text-xs p-2 bg-primary absolute bottom-0 right-0 rounded-br-md">
Total images ({locationImages.images.length})
</div>
)}
</div>
{/* Thumbnail strip */}
{locationImages?.images.length > 1 && (
<div className="flex gap-2 mt-2 overflow-x-auto">
{locationImages.images.map((image, index) => (
<img
key={index}
src={image.src}
alt=""
className={`w-16 h-16 object-cover cursor-pointer flex-shrink-0 ${index === currentIndex ? 'ring-2 ring-primary' : 'opacity-60'
}`}
onClick={() => setCurrentIndex(index)}
/>
))}
</div>
)}
</div>
</div>
)}
<div className="ml-8 inline-block mt-3 bg-primary text-sm p-[15px] w-[45%] rounded-md align-top border border-[#38444d] h-[420px]">
<div className="pb-1 border-b border-[#38444d]">
<h2 className="inline-block font-bold text-md tracking-[0.9px]">DETAILS</h2>
<div className="float-right text-[12px] tracking-[0.9px]">
<a class="group-hover:opacity-100 transition-opacity duration-200 underline underline-offset-4" href="#">SUBMIT CORRECTION</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Address:</div>
<div className="capitalize text-md mb-3">{locationDetail.detail.address} {locationDetail.detail.regency_name}</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Province:</div>
<div className="capitalize text-md mb-3">
<a href={'#'} className="hover:text-white"> {locationDetail.detail.province_name}</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Region:</div>
<div className="capitalize text-md mb-3">
<a href={'#'} className="hover:text-white"> {locationDetail.detail.region_name}</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Average Cost</div>
<div className="text-md mb-3">Rp 25.000</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1 underline">
<a href={locationDetail.detail.google_maps_link.toString()} target={'_'}>Maps Location</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Tags :</div>
<div className="flex flex-wrap gap-1.5">
{locationDetail.tags.map((x, index) => (
<div key={index} className="px-2 py-0.5 text-md bg-[#484848] rounded-[3px] hover:text-white">
<a href={'#'}>Badge</a>
</div>
))}
<div className="px-2 py-0.5 text-md bg-[#484848] rounded-[3px] hover:text-white">
<a href={'#'}>+ add more</a>
</div>
</div>
</div>
</div>
</div>
<RatingsCard
data={locationDetail}
getCriticData={(data) => ({
score: Number(data.detail.critic_score),
count: Number(data.detail.critic_count)
})}
getUserData={(data) => ({
score: Number(data.detail.user_score),
count: Number(data.detail.user_count)
})}
getCriticDetails={() => ({
environment: 85,
cleanliness: 90,
price: 75,
facility: 80
})}
getUserDetails={() => ({
environment: 82,
cleanliness: 88,
price: 70,
facility: 78
})}
{!isLoading && (() => {
const imgs = locationImages?.images ?? [];
const total = imgs.length;
if (total === 0) return null;
const openAt = (i: number) => { setCurrentIndex(i); setLightboxOpen(true); };
const ImageTile = ({ img, index, className }: { img: typeof imgs[0], index: number, className: string }) => (
<div
className={`relative overflow-hidden cursor-zoom-in group/tile ${className}`}
onClick={() => openAt(index)}
>
<img
src={img.src}
alt=""
className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75"
/>
<div className="absolute bottom-0 left-0 right-0 p-2 flex justify-between items-end pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
Photo {index + 1}
</span>
</div>
</div>
);
return (
<div className="mt-4 relative w-full">
{total === 1 && (
<div className="h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="h-full" />
</div>
)}
{total === 2 && (
<div className="grid grid-cols-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="" />
<ImageTile img={imgs[1]} index={1} className="" />
</div>
)}
{total === 3 && (
<div className="grid grid-cols-4 grid-rows-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="col-span-2 row-span-2" />
<ImageTile img={imgs[1]} index={1} className="col-span-2" />
<ImageTile img={imgs[2]} index={2} className="col-span-2" />
</div>
)}
{total >= 4 && (
<div className="grid grid-cols-4 grid-rows-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
{/* Top-left large */}
<ImageTile img={imgs[0]} index={0} className="col-span-2 row-span-1 max-[768px]:col-span-4 max-[768px]:row-span-2" />
{/* Right full-height */}
<div
className="col-span-2 row-span-2 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(1)}
>
<img src={imgs[1].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
<div className="absolute bottom-0 left-0 right-0 p-2 flex justify-between items-end pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 2</span>
{total > 4 && (
<span className="text-white text-xs font-semibold bg-black/50 rounded px-1.5 py-0.5">
🖼 {total.toLocaleString()}
</span>
)}
</div>
</div>
{/* Bottom-left small */}
<div
className="col-span-1 row-span-1 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(2)}
>
<img src={imgs[2].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
<div className="absolute bottom-0 left-0 right-0 p-2 pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 3</span>
</div>
</div>
{/* Bottom-middle small — with "+more" if applicable */}
<div
className="col-span-1 row-span-1 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(3)}
>
<img src={imgs[3].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
{total > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
<span className="text-white font-bold text-2xl">+{total - 4}</span>
</div>
)}
{total <= 4 && (
<div className="absolute bottom-0 left-0 right-0 p-2 pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 4</span>
</div>
)}
</div>
</div>
)}
</div>
);
})()}
<div className="mt-4 flex flex-row items-center gap-5 border border-[#38444d] rounded-md px-5 py-3 text-sm w-fit mx-auto">
<a href="#" className="flex flex-row items-center gap-1.5 underline underline-offset-2 hover:text-white transition-colors">
<Globe className="w-4 h-4" />
<span>Website</span>
</a>
<a href="#" className="flex flex-row items-center gap-1.5 underline underline-offset-2 hover:text-white transition-colors">
<MenuIcon fill={'#ffffff'} className="w-5 h-5" />
<span>Menu</span>
</a>
{/* <a href="#" className="flex flex-row items-center gap-1.5 font-bold underline underline-offset-2 hover:text-white transition-colors">
<Phone className="w-4 h-4" />
<span>-</span>
</a> */}
<div className="w-px h-4 bg-[#38444d]" />
<a href="#" className="hover:text-white transition-colors underline underline-offset-2">Improve this listing</a>
</div>
<div className="flex flex-col md:flex-row gap-4 items-stretch mt-2 bg-black/[0.27] -mx-[25px] px-[25px] pb-4 md:mx-0 md:px-5 md:py-5 md:rounded-xl">
<div className="flex-1 min-w-0">
<RatingsCard
data={locationDetail}
getCriticData={(data) => ({
score: Number(data.detail.critic_score),
count: Number(data.detail.critic_count)
})}
getUserData={(data) => ({
score: Number(data.detail.user_score),
count: Number(data.detail.user_count)
})}
getCriticDetails={() => ({
environment: 85,
cleanliness: 90,
price: 75,
facility: 80
})}
getUserDetails={() => ({
environment: 82,
cleanliness: 88,
price: 70,
facility: 78
})}
/>
</div>
<div className="w-full md:w-[320px] flex-shrink-0">
<ScheduleCard
schedules={[
{ day: 'Sunday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Monday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Tuesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Wednesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Thursday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Friday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Saturday', open: '7.00 AM', close: '21.00 PM' },
]}
onSuggestEdit={() => console.log('suggest edit')}
/>
</div>
</div>
<FacilitiesCard
seeMoreShow
title="Facilities"
left={[
{
title: 'Main Facilities',
items: [
{ text: '5-floor main prayer hall + 1 basement (capacity 200,000+ worshippers)' },
{ text: 'Advanced high-quality sound system' },
{ text: 'Spacious courtyard for Eid prayers' },
],
},
]}
middle={[
{
title: 'Supporting Facilities',
items: [
{ text: 'Islamic library aoehantdhouantehou asntoehu sote ueaaup,' },
{ text: 'Multipurpose hall' },
{ text: 'Large and comfortable ablution area' },
],
},
]}
right={[
{
title: 'Accessibility',
items: [
{ text: 'Disability-friendly facilities' },
{ text: 'Guiding blocks for the visually impaired' },
{ text: 'Elevators with voice guidance & transparent walls' },
],
},
]}
/>
</section>
@ -394,86 +496,251 @@ function LocationDetail() {
{!user.username ?
<div className="bg-secondary text-center p-3 w-full"><a href={'#'} onClick={handleSignInNavigation} className="border-b border-white">SIGN IN</a> TO REVIEW</div>
:
<div name="REVIEW INPUT TEXTAREA" className="p-4 bg-secondary">
<div className="w-3/4 mx-auto max-[1024px]:w-full [&_input]:outline-none">
<div name="REVIEW INPUT TEXTAREA" className="rounded-xl bg-black/[0.27] p-5">
<div className="w-[55px] float-left mr-3">
<a href={'#'}>
<img loading={'lazy'} src={user.avatar_picture != '' ? user.avatar_picture.toString() : DEFAULT_AVATAR_IMG} className="aspect-square" />
</a>
{/* Avatar + Username */}
<div className="flex items-center gap-3 mb-5">
<img
loading="lazy"
src={user.avatar_picture ? user.avatar_picture.toString() : DEFAULT_AVATAR_IMG}
className="w-10 h-10 rounded-full object-cover flex-shrink-0"
/>
<span className="font-semibold">{user.username}</span>
{currentUserReview && (
<>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-3 bg-secondary rounded-lg px-4 py-1.5">
<span className="text-sm tracking-wide">{user.is_critics ? "CRITIC SCORE" : "USER SCORE"}</span>
<span className="text-sm font-bold bg-primary rounded-full w-8 h-8 flex items-center justify-center">{currentUserReview.score}</span>
</div>
</>
)}
</div>
{currentUserReview ? (
<div>
{currentUserReview.created_at?.Valid && (
<span className="text-xs text-tertiary mb-4 block">
Written {new Date(String(currentUserReview.created_at.Time)).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
)}
{currentUserReview.title && (
<p className="font-bold text-2xl mb-3">{currentUserReview.title}</p>
)}
{currentUserReview.images && currentUserReview.images.length > 0 && (
<div className="grid grid-cols-4 gap-1.5 mb-4 rounded-lg overflow-hidden">
{currentUserReview.images.slice(0, 4).map((img, i) => (
<div
key={img.id}
className="relative aspect-[6/3] cursor-zoom-in group/rimg overflow-hidden"
onClick={() => { setReviewLightboxIndex(i); setReviewLightboxOpen(true); }}
>
<img
src={img.src}
alt={`review-${i}`}
className="w-full h-full object-cover transition-all duration-200 group-hover/rimg:brightness-75"
/>
{i === 3 && currentUserReview.images!.length > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
<span className="text-white font-bold text-lg">+{currentUserReview.images!.length - 4} MORE</span>
</div>
)}
</div>
))}
</div>
)}
{/* Review text — clamped with "Show Full Review" */}
<div className="mb-4">
<div className="line-clamp-5">
<CustomInterweave content={currentUserReview.comments} />
</div>
</div>
{/* Footer: likes, comments, show full review */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-tertiary">
<button className="flex items-center gap-1.5 hover:text-white transition-colors text-sm">
<Heart size={18} />
<span>15</span>
</button>
<button className="flex items-center gap-1.5 hover:text-white transition-colors text-sm">
<MessageCircle size={18} />
<span>5</span>
</button>
</div>
<button className="border border-gray-600 rounded-lg px-4 py-2 text-sm font-semibold hover:bg-secondary transition-colors">
Show Full Review
</button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-3 gap-3 mb-5 [&_select]:w-full [&_select]:bg-black/40 [&_select]:border [&_select]:border-gray-600 [&_select]:rounded-lg [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:outline-none [&_select]:appearance-none [&_select]:cursor-pointer [&_select:focus]:border-gray-400">
<div>
<label className="text-xs text-tertiary block mb-1.5">Rating</label>
<select
value={reviewValue.score_input}
onChange={(e) => {
const val = (e.target as HTMLSelectElement).value;
setReviewValue({ ...reviewValue, score_input: val });
setPageState({ ...pageState, is_score_rating_panic_msg: '' });
}}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Rasa</label>
<select
value={reviewValue.rasa}
onChange={(e) => setReviewValue({ ...reviewValue, rasa: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Suasana</label>
<select
value={reviewValue.suasana}
onChange={(e) => setReviewValue({ ...reviewValue, suasana: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div />
<div>
<label className="text-xs text-tertiary block mb-1.5">Pelayanan</label>
<select
value={reviewValue.pelayanan}
onChange={(e) => setReviewValue({ ...reviewValue, pelayanan: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Kebersihan</label>
<select
value={reviewValue.kebersihan}
onChange={(e) => setReviewValue({ ...reviewValue, kebersihan: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
<div className="block">
<a href={'#'}>{user.username}</a>
</div>
{pageState.is_score_rating_panic_msg && (
<div className="text-xs text-error mb-3">{pageState.is_score_rating_panic_msg}</div>
)}
<div className={currentUserReview ? "m-0 mb-2.5" : "my-1.5 mx-0"}>
{currentUserReview ?
<div className="inline-block">
<p className="ml-2">{currentUserReview.score}</p>
<div className="h-1 w-[35px] bg-[#72767d]">
<div className="h-1 bg-brand-green" style={{ width: `${currentUserReview.score}%` }} />
{/* Hidden file input */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageSelect}
/>
<button
onClick={() => imageInputRef.current?.click()}
className={`flex items-center gap-2 border border-gray-600 rounded-lg px-4 py-2 text-sm hover:bg-primary transition-colors text-tertiary hover:text-white ${uploadedImages.length === 0 ? 'mb-5' : ''}`}
>
<ImagePlus size={16} />
Add Images {uploadedImages.length > 0 && `(${uploadedImages.length})`}
</button>
{uploadedImages.length > 0 && (
<div className="relative mt-3 mb-5">
<div className="pointer-events-none absolute right-0 top-0 h-full w-12 bg-gradient-to-l from-black/[0.27] to-transparent z-10 rounded-r-lg" />
<div className="flex flex-row gap-2 overflow-x-auto pb-2 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb:hover]:bg-gray-400">
{uploadedImages.map((img, i) => (
<div key={i} className="relative flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden group/thumb">
{/* Click image to open lightbox */}
<img
src={img.preview}
alt={`upload-${i}`}
className="w-full h-full object-cover cursor-zoom-in"
onClick={() => openUploadLightbox(i)}
/>
{/* X button — top-right corner, always visible */}
<button
onClick={(e) => { e.stopPropagation(); removeImage(i); }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/70 flex items-center justify-center text-white text-xs leading-none hover:bg-black transition-colors"
>
&times;
</button>
</div>
))}
</div>
</div>
:
<>
<input
type={'text'}
pattern={"\d*"}
className="text-xs bg-[#40444b] text-center w-10 h-5 leading-[18px] border border-[#38444d]"
maxLength={3}
value={reviewValue.score_input}
onChange={handleScoreInputChange}
placeholder={"0-100"}
autoComplete={'off'}
)}
<div className="mb-3">
<label className="text-xs text-tertiary block mb-1.5">Title your review</label>
<input
type="text"
placeholder="Give us the gist of your experience"
value={reviewValue.title}
onChange={(e) => setReviewValue({ ...reviewValue, title: (e.target as HTMLInputElement).value })}
className="w-full bg-transparent border border-gray-600 rounded-lg px-3 py-2 text-sm outline-none focus:border-gray-400 transition-colors placeholder:text-quartenary"
/>
</div>
<div className="mb-4">
<label className="text-xs text-tertiary block mb-1.5">Write your review</label>
<div className="relative">
<ReactTextareaAutosize
minRows={4}
onChange={handleTextAreaChange}
ref={textAreaRef}
placeholder="Share your experience......."
value={reviewValue.review_textArea}
className="w-full bg-transparent border border-gray-600 rounded-lg px-3 py-2 text-sm outline-none focus:border-gray-400 transition-colors resize-none placeholder:text-quartenary pb-8 &::-webkit-scrollbar]:hidden [scrollbar-width:none]"
/>
<div className="inline-block text-xs ml-2 text-tertiary">/ score</div>
{pageState.is_score_rating_panic_msg &&
<div className="inline-block text-xs ml-2 text-error">{pageState.is_score_rating_panic_msg}</div>
}
</>
}
<div className="clear-both" />
</div>
<div className="mt-3 w-full">
{currentUserReview ?
<CustomInterweave
content={currentUserReview.comments}
/>
:
<ReactTextareaAutosize
onChange={handleTextAreaChange}
ref={textAreaRef}
className="p-2 text-area text-sm"
value={reviewValue.review_textArea}
/>
}
</div>
<div class='flex justify-between'>
<div className='flex hover:text-tertiary text-[white] cursor-pointer'>
<ImagePlus className='text-inherit mr-1' size={20} />
<p className='text-sm'>add image</p>
</div>
<div className="text-right">
<div className="inline-block text-[11px] align-middle mr-2.5 tracking-[0.5px]">
<a>Review Guidelines</a>
<button className="absolute bottom-3 right-3 text-tertiary hover:text-white transition-colors text-base leading-none">
<Smile width={24} height={24} />
</button>
</div>
{pageState.on_submit_loading ?
<SpinnerLoading />
:
<span className={`text-xxs p-1 bg-gray tracking-[1px] ${!pageState.enable_post ? 'hidden' : ''}`}>
<a href={'#'} onClick={handleSubmitReview}>
POST
</a>
</span>
}
</div>
</div>
</div>
</div>
<div className="flex justify-end">
{pageState.on_submit_loading ? (
<SpinnerLoading />
) : (
pageState.enable_post && (
<a
href="#"
onClick={handleSubmitReview}
className="bg-primary border border-gray-600 rounded-lg px-8 py-2 text-sm font-semibold hover:bg-secondary transition-colors"
>
Post
</a>
)
)}
</div>
</>
)}
</div>
}
<div name={'CRTICITS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
@ -552,47 +819,50 @@ function LocationDetail() {
{locationDetail.users_review.length > 0 ?
<>
{locationDetail.users_review.map(x => (
<div className="py-[15px] px-0">
<div className="mr-5 w-[45px] float-left">
<a href="#">
<img
loading={'lazy'}
className="w-full"
src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
</a>
</div>
<div>
<div className="font-bold text-base leading-none">
<a>
<span>{x.username}</span>
</a>
</div>
</div>
<div className="inline-block">
<div className="text-sm text-center">{x.score}</div>
<div className="h-1 w-[25px] relative bg-[#d8d8d8]">
<div className="h-1 bg-[#85ce73]" style={{ width: `${x.score}%` }} />
</div>
</div>
<div className="text-[15px] leading-6 my-2.5 mx-[65px] mb-[1px] break-words">
<CustomInterweave
content={x.comments}
<div key={x.id} className="py-4 border-b border-[#38444d] last:border-b-0">
{/* Header: avatar + username + score */}
<div className="flex items-center gap-3 mb-3">
<img
loading="lazy"
className="w-10 h-10 rounded-full object-cover flex-shrink-0"
src={x.user_avatar ?? DEFAULT_AVATAR_IMG}
/>
<span className="font-semibold text-sm">{x.username}</span>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-2 bg-secondary rounded-lg px-3 py-1">
<span className="text-xs tracking-wide text-tertiary">USER SCORE</span>
<span className="text-xs font-bold bg-primary rounded-full w-6 h-6 flex items-center justify-center">{x.score}</span>
</div>
</div>
<div className="ml-[63px]">
<div className="mr-2 min-w-[55px] inline-block align-middle">
<a className="text-sm" href={'#'}>
<svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className="inline-block">Video</div>
</a>
</div>
<div className="min-w-[55px] inline-block align-middle">
<a className="text-sm" href={'#'}>
<svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className="inline-block">Instagram</div>
</a>
{/* Title */}
{x.title && (
<p className="font-bold text-xl underline mb-2">{x.title}</p>
)}
{/* Images */}
{x.images && x.images.length > 0 && (
<div className="grid grid-cols-4 gap-1.5 mb-3 rounded-lg overflow-hidden">
{x.images.slice(0, 4).map((img, i) => (
<div key={img.id} className="relative aspect-[4/3] overflow-hidden rounded">
<img
src={img.src}
alt={`review-img-${i}`}
className="w-full h-full object-cover"
/>
{i === 3 && x.images!.length > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center">
<span className="text-white font-bold text-sm">+{x.images!.length - 4} MORE</span>
</div>
)}
</div>
))}
</div>
)}
{/* Comment */}
<div className="text-sm leading-6 break-words line-clamp-5">
<CustomInterweave content={x.comments} />
</div>
</div>
))}
@ -630,6 +900,18 @@ function LocationDetail() {
close={() => setLightboxOpen(false)}
slides={locationImages?.images}
/>
<Lightbox
open={uploadLightboxOpen}
close={() => setUploadLightboxOpen(false)}
index={uploadLightboxIndex}
slides={uploadedImages.map(img => ({ src: img.preview }))}
/>
<Lightbox
open={reviewLightboxOpen}
close={() => setReviewLightboxOpen(false)}
index={reviewLightboxIndex}
slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []}
/>
</div>
)
}

View File

@ -37,13 +37,15 @@ export function emptyLocationDetail(): ILocationDetail {
export interface LocationReviewsResponse {
id: number,
title?: string,
score: number,
comments: string,
user_id: number,
username: string,
user_avatar: string | null,
created_at: string,
updated_at: string
updated_at: string,
images?: Array<{ id: number, src: string }>
}
export interface LocationDetailResponse {
@ -84,6 +86,7 @@ export function emptyLocationResponse(): LocationResponse {
export type CurrentUserLocationReviews = {
id: number,
title?: string,
comments: string,
is_from_critic: boolean,
is_hided: boolean,
@ -92,4 +95,5 @@ export type CurrentUserLocationReviews = {
submitted_by: number,
created_at: NullValueRes<"Time", string>,
updated_at: NullValueRes<"Time", string>,
images?: Array<{ id: number, src: string }>,
}

View File

@ -2,7 +2,7 @@ import { ChangeEvent, TargetedEvent, useEffect, useRef, useState } from "preact/
import { DefaultButton, NavigationSeparator } from "../../components";
import { News } from "../../domains/NewsEvent";
import { getNewsServices, postNewsService } from "../../../src/services";
import { AxiosError } from "axios";
import { DEFAULT_LOCATION_THUMBNAIL_IMG } from "../../../src/constants/default";
import { isUrl, useAutosizeTextArea } from "../../../src/utils";
import ReactTextareaAutosize from "react-textarea-autosize";
@ -41,7 +41,7 @@ function NewsEvent() {
}
console.log(news)
} catch (error) {
let err = error as AxiosError;
const err = error as IHttpResponse;
if (!err.status) {
alert('Server is in trouble, probably dead RIP');
}

View File

@ -82,7 +82,7 @@ function UserProfile() {
<img
src={user.avatar_picture !== '' ? user.avatar_picture : DEFAULT_AVATAR_IMG}
style={{ width: 140, aspectRatio: '1/1', float: 'left' }}
className={'mr-4'}
className={'mr-4 rounded-2xl'}
/>
<p className={'text-lg'}>{user.username}</p>
{/* <div className={'mt-2'}>
@ -147,6 +147,7 @@ function UserProfile() {
<a href="#">
<img
loading={'lazy'}
className='rounded-lg'
style={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
src={x.thumbnail !== '' ? x.thumbnail : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>

View File

@ -9,7 +9,7 @@ import { SocialMediaEnum } from "../../types/common";
import { SocialMedia, UserInfo } from "../../../src/domains/User";
import './styles.css'
import { deleteUserAvatarService, patchUserAvatarService, patchUserInfoService } from "../../../src/services/users";
import { AxiosError } from "axios";
import { useDispatch } from "react-redux";
import { authAdded } from "../../features/auth/authSlice/authSlice";
@ -93,8 +93,7 @@ function UserSettings() {
dispatch(authAdded(userStore));
setIsLoading(false)
} catch(err) {
let error = err as AxiosError;
console.log(error)
console.log(err)
setIsLoading(false)
}
@ -116,8 +115,7 @@ function UserSettings() {
setIsLoading(false)
} catch(err) {
setIsLoading(false)
const error = err as AxiosError
console.log(error)
console.log(err)
}
}
@ -138,8 +136,8 @@ function UserSettings() {
setIsLoading(false)
}catch(err) {
setIsLoading(false)
let error = err as AxiosError;
alert(error)
console.log(err)
alert(err)
}
}

View File

@ -20,10 +20,8 @@ export async function client<T = any>(config: FetchConfig): Promise<{ data: T; s
credentials: withCredentials ? 'include' : 'same-origin',
};
// Handle body data
if (data) {
if (data instanceof FormData) {
// Remove Content-Type header for FormData to let browser set it with boundary
const headersObj = fetchOptions.headers as Record<string, string>;
delete headersObj['Content-Type'];
fetchOptions.body = data;

View File

@ -7,7 +7,7 @@ import {
} from "./locations";
import { getImagesByLocationService } from "./images"
import { createAccountService, loginService, logoutService } from "./auth";
import { postReviewLocation, getCurrentUserLocationReviewService } from "./review";
import { postReviewLocation, postReviewImages, getCurrentUserLocationReviewService } from "./review";
import { getRegionsService, getProvincesService, getRegenciesService} from "./regions";
import { getUserStatsService } from "./users";
import { getNewsServices, postNewsService} from "./news";
@ -31,6 +31,7 @@ export {
getImagesByLocationService,
postReviewLocation,
postReviewImages,
getCurrentUserLocationReviewService,
getNewsServices,

View File

@ -1,6 +1,6 @@
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { client } from "./config";
import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_LOCATION_URI } from "../constants/api";
import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_IMAGES_URI, POST_REVIEW_LOCATION_URI } from "../constants/api";
import { IHttpResponse } from "src/types/common";
interface postReviewLocationReq {
@ -9,7 +9,8 @@ interface postReviewLocationReq {
score: number,
is_from_critic: boolean,
is_hided: boolean,
location_id: number
location_id: number,
title: string
}
// API Functions
@ -63,7 +64,16 @@ async function getCurrentUserLocationReviewService(location_id: number): Promise
}
}
async function postReviewImages(reviewId: number, files: File[]) {
const form = new FormData()
form.append('review_id', String(reviewId))
files.forEach(f => form.append('images', f))
const response = await client({ method: 'POST', url: POST_REVIEW_IMAGES_URI, data: form, withCredentials: true })
return response.data
}
export {
postReviewLocation,
postReviewImages,
getCurrentUserLocationReviewService,
}

View File

@ -35,11 +35,11 @@ export interface IndonesiaRegionsInfo {
}
export enum LocationType {
Beach = "beach",
AmusementPark = "amusement park",
TraditionalMarket = "traditional market",
Mall = "mall",
Recreation = "recreation",
Culinary = "culinary",
HikingCamping = "hiking / camping",
Other = "other"
Accommodation = "accommodation",
}
// https://www.similarweb.com/top-websites/indonesia/computers-electronics-and-technology/social-networks-and-online-communities/#:~:text=facebook.com%20ranked%20number%201,Media%20Networks%20websites%20in%20Indonesia.

View File

@ -1,9 +1,14 @@
import { AxiosError } from "axios";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function handleAxiosError(error: AxiosError) {
return error.response?.data
export function handleApiError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error !== null && 'message' in error) {
return String((error as { message: unknown }).message)
}
return 'An unexpected error occurred'
}
export function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {

View File

@ -1,9 +1,9 @@
import useAutosizeTextArea from "./useAutosizeTextArea";
import { handleAxiosError, enumKeys, isUrl } from "./common";
import { handleApiError, enumKeys, isUrl } from "./common";
export {
useAutosizeTextArea,
handleAxiosError,
handleApiError,
enumKeys,
isUrl
}

12
src/utils/useIsMobile.ts Normal file
View File

@ -0,0 +1,12 @@
import { useState, useEffect } from 'preact/hooks';
export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [breakpoint]);
return isMobile;
}

View File

@ -13,7 +13,8 @@ export default {
'brand-yellow': '#e5c453',
'rating-red': '#CE7385',
'rating-green': '#85CE73',
'rating-yellow': '#DECA21'
'rating-yellow': '#DECA21',
'card-primary': '#050505',
},
borderColor: {
primary: '#38444d',

View File

@ -6,6 +6,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": "./",
"ignoreDeprecations": "6.0",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]