Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83c8c3a121 | |||
| 3d2de6fd4b | |||
| 2248845467 | |||
| b19c1cd3ca | |||
| 2465ae1020 | |||
| 0cb119629c | |||
| da42e42b7c | |||
| 6849062f28 | |||
| 3409b8114e | |||
| 4c32f06da7 | |||
| a9fc7c4ff7 | |||
| e10177a788 | |||
| 07ea4a961b | |||
| 234729329c | |||
| 81fc074708 | |||
| f2172e537d | |||
| f8bddfb428 | |||
| 29ad49185b | |||
| cb2a0e85c4 | |||
| 9e70565748 | |||
| db15454a83 | |||
| 7ff5570c3f | |||
| 136acf1f4c | |||
| 5921ac0bc7 | |||
| 30e6279cb2 | |||
| 1bfd61f496 | |||
| c1f2851e25 | |||
| 7c12b19511 | |||
| a14be45b76 | |||
| 6bdb3c80af | |||
| 816b278283 | |||
| c6a2a54806 | |||
| 4bfebeab35 | |||
| 1d0ffe6a11 | |||
| fae7da51fa | |||
| bcfaceae94 | |||
| 5ea96647ad | |||
| d592dd525e | |||
| 5f8812ce55 | |||
| a53ffaf619 | |||
| ecd43b0307 | |||
| 62a255cb70 | |||
| 201bb768c9 | |||
| 037e94d7ac | |||
| cee1f98972 | |||
| a0b0266b1a | |||
| c282edd0c6 | |||
| 8ff2d4adf3 | |||
| 80acaf743c | |||
| bace34a20f | |||
| b2d4f7848f | |||
| e8a950b0b6 | |||
| 3bb6ffac4e | |||
| ac049ef067 | |||
| 97b1cddf85 | |||
| ed5df3d7b7 | |||
| c565c0957f | |||
| d34a3a92f1 | |||
| de45b1f197 | |||
| ba82744219 | |||
| b5653906dc | |||
| d3ef8e1a98 | |||
| f6cfa369fc | |||
| f002d3c658 | |||
| 68a290edfa | |||
| e350e22399 | |||
| 78e4097d8c | |||
| 461112c8ab | |||
| 4bf4d57a18 |
+3
-3
@@ -3,11 +3,11 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Change Alpine repo
|
||||
# RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
|
||||
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
|
||||
|
||||
# Configure npm registry mirror (Liara)
|
||||
# ENV NPM_CONFIG_REGISTRY=https://mirror-npm.runflare.com
|
||||
# RUN npm config set registry https://mirror-npm.runflare.com
|
||||
ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
|
||||
|
||||
# Install tzdata to support timezone settings
|
||||
# RUN apk add --no-cache tzdata && \
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
@@ -0,0 +1,101 @@
|
||||
<svg width="299" height="97" viewBox="0 0 299 97" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M148.337 7.64759C148.337 11.8811 148.474 16.1145 148.337 20.348C148.2 23.489 146.147 25.8106 143.683 27.7225C143.273 27.9956 142.862 28.2687 142.178 28.815C143.41 29.6344 144.368 30.3172 145.463 31.1366H118.5C118.911 31 119.184 30.8634 119.321 30.8634C123.29 30.0441 127.533 29.7709 131.365 28.1322C140.399 24.3084 146.01 17.6168 148.2 8.05728L148.337 7.64759Z" fill="url(#paint0_linear_13943_20144)"/>
|
||||
<path d="M152.169 23.3524C151.759 23.8987 151.211 24.4449 150.937 24.8546C150.527 25.4009 150.39 25.9471 149.979 26.63V0C149.979 0 149.979 0 150.116 0C150.527 2.04846 150.801 3.96035 151.211 6.00881C152.99 16.3877 158.739 23.7621 168.456 27.859C170.099 28.5419 172.015 28.9515 173.794 29.6344C173.657 29.6344 173.52 29.6344 173.384 29.6344C169.962 29.6344 166.677 29.6344 163.255 29.6344C158.739 29.7709 155.454 27.7225 152.854 24.0352C152.854 23.8987 152.58 23.6256 152.443 23.489C152.443 23.489 152.443 23.489 152.169 23.3524Z" fill="url(#paint1_linear_13943_20144)"/>
|
||||
<path d="M126.986 32.3656C130.681 32.3656 134.514 32.3656 138.209 32.3656C141.768 32.3656 144.368 34.4141 146.421 37.1454C146.695 37.5551 146.969 37.9648 147.516 38.6476C148.337 37.5551 149.022 36.4626 149.843 35.5066V62C149.432 59.815 149.158 57.7665 148.748 55.5815C146.558 43.5639 139.578 35.9163 127.67 32.6388C127.533 32.6388 127.26 32.6388 127.123 32.5022C127.123 32.5022 127.123 32.5022 127.123 32.3656H126.986Z" fill="url(#paint2_linear_13943_20144)"/>
|
||||
<path d="M151.347 53.8062C151.347 49.8458 151.211 45.8855 151.347 41.9251C151.484 38.6476 153.4 36.1894 156.001 34.2775C156.411 34.0044 156.822 33.7313 157.506 33.185C156.411 32.3656 155.317 31.6828 154.222 31H180.5C178.721 31.2731 177.078 31.6828 175.299 31.9559C168.866 32.9119 163.255 35.5066 158.738 40.1498C155.18 43.7004 152.853 47.9339 151.621 52.8502C151.621 53.1234 151.484 53.3965 151.347 53.8062Z" fill="url(#paint3_linear_13943_20144)"/>
|
||||
<path d="M148.337 7.64759C148.337 11.8811 148.474 16.1145 148.337 20.348C148.2 23.489 146.147 25.8106 143.683 27.7225C143.273 27.9956 142.862 28.2687 142.178 28.815C143.41 29.6344 144.368 30.3172 145.463 31.1366H118.5C118.911 31 119.184 30.8634 119.321 30.8634C123.29 30.0441 127.533 29.7709 131.365 28.1322C140.399 24.3084 146.01 17.6168 148.2 8.05728L148.337 7.64759Z" fill="url(#paint4_linear_13943_20144)"/>
|
||||
<path d="M152.169 23.3524C151.759 23.8987 151.211 24.4449 150.937 24.8546C150.527 25.4009 150.39 25.9471 149.979 26.63V0C149.979 0 149.979 0 150.116 0C150.527 2.04846 150.801 3.96035 151.211 6.00881C152.99 16.3877 158.739 23.7621 168.456 27.859C170.099 28.5419 172.015 28.9515 173.794 29.6344C173.657 29.6344 173.52 29.6344 173.384 29.6344C169.962 29.6344 166.677 29.6344 163.255 29.6344C158.739 29.7709 155.454 27.7225 152.854 24.0352C152.854 23.8987 152.58 23.6256 152.443 23.489C152.443 23.489 152.443 23.489 152.169 23.3524Z" fill="url(#paint5_linear_13943_20144)"/>
|
||||
<path d="M126.986 32.3656C130.681 32.3656 134.514 32.3656 138.209 32.3656C141.768 32.3656 144.368 34.4141 146.421 37.1454C146.695 37.5551 146.969 37.9648 147.516 38.6476C148.337 37.5551 149.022 36.4626 149.843 35.5066V62C149.432 59.815 149.158 57.7665 148.748 55.5815C146.558 43.5639 139.578 35.9163 127.67 32.6388C127.533 32.6388 127.26 32.6388 127.123 32.5022C127.123 32.5022 127.123 32.5022 127.123 32.3656H126.986Z" fill="url(#paint6_linear_13943_20144)"/>
|
||||
<path d="M151.347 53.8062C151.347 49.8458 151.211 45.8855 151.347 41.9251C151.484 38.6476 153.4 36.1894 156.001 34.2775C156.411 34.0044 156.822 33.7313 157.506 33.185C156.411 32.3656 155.317 31.6828 154.222 31H180.5C178.721 31.2731 177.078 31.6828 175.299 31.9559C168.866 32.9119 163.255 35.5066 158.738 40.1498C155.18 43.7004 152.853 47.9339 151.621 52.8502C151.621 53.1234 151.484 53.3965 151.347 53.8062Z" fill="url(#paint7_linear_13943_20144)"/>
|
||||
<path d="M148.337 7.64759C148.337 11.8811 148.474 16.1145 148.337 20.348C148.2 23.489 146.147 25.8106 143.683 27.7225C143.273 27.9956 142.862 28.2687 142.178 28.815C143.41 29.6344 144.368 30.3172 145.463 31.1366H118.5C118.911 31 119.184 30.8634 119.321 30.8634C123.29 30.0441 127.533 29.7709 131.365 28.1322C140.399 24.3084 146.01 17.6168 148.2 8.05728L148.337 7.64759Z" fill="url(#paint8_linear_13943_20144)"/>
|
||||
<path d="M152.169 23.3524C151.759 23.8987 151.211 24.4449 150.937 24.8546C150.527 25.4009 150.39 25.9471 149.979 26.63V0C149.979 0 149.979 0 150.116 0C150.527 2.04846 150.801 3.96035 151.211 6.00881C152.99 16.3877 158.739 23.7621 168.456 27.859C170.099 28.5419 172.015 28.9515 173.794 29.6344C173.657 29.6344 173.52 29.6344 173.384 29.6344C169.962 29.6344 166.677 29.6344 163.255 29.6344C158.739 29.7709 155.454 27.7225 152.854 24.0352C152.854 23.8987 152.58 23.6256 152.443 23.489C152.443 23.489 152.443 23.489 152.169 23.3524Z" fill="url(#paint9_linear_13943_20144)"/>
|
||||
<path d="M126.986 32.3656C130.681 32.3656 134.514 32.3656 138.209 32.3656C141.768 32.3656 144.368 34.4141 146.421 37.1454C146.695 37.5551 146.969 37.9648 147.516 38.6476C148.337 37.5551 149.022 36.4626 149.843 35.5066V62C149.432 59.815 149.158 57.7665 148.748 55.5815C146.558 43.5639 139.578 35.9163 127.67 32.6388C127.533 32.6388 127.26 32.6388 127.123 32.5022C127.123 32.5022 127.123 32.5022 127.123 32.3656H126.986Z" fill="url(#paint10_linear_13943_20144)"/>
|
||||
<path d="M151.347 53.8062C151.347 49.8458 151.211 45.8855 151.347 41.9251C151.484 38.6476 153.4 36.1894 156.001 34.2775C156.411 34.0044 156.822 33.7313 157.506 33.185C156.411 32.3656 155.317 31.6828 154.222 31H180.5C178.721 31.2731 177.078 31.6828 175.299 31.9559C168.866 32.9119 163.255 35.5066 158.738 40.1498C155.18 43.7004 152.853 47.9339 151.621 52.8502C151.621 53.1234 151.484 53.3965 151.347 53.8062Z" fill="url(#paint11_linear_13943_20144)"/>
|
||||
<path d="M148.337 7.64759C148.337 11.8811 148.474 16.1145 148.337 20.348C148.2 23.489 146.147 25.8106 143.683 27.7225C143.273 27.9956 142.862 28.2687 142.178 28.815C143.41 29.6344 144.368 30.3172 145.463 31.1366H118.5C118.911 31 119.184 30.8634 119.321 30.8634C123.29 30.0441 127.533 29.7709 131.365 28.1322C140.399 24.3084 146.01 17.6168 148.2 8.05728L148.337 7.64759Z" fill="url(#paint12_linear_13943_20144)"/>
|
||||
<path d="M152.169 23.3524C151.759 23.8987 151.211 24.4449 150.937 24.8546C150.527 25.4009 150.39 25.9471 149.979 26.63V0C149.979 0 149.979 0 150.116 0C150.527 2.04846 150.801 3.96035 151.211 6.00881C152.99 16.3877 158.739 23.7621 168.456 27.859C170.099 28.5419 172.015 28.9515 173.794 29.6344C173.657 29.6344 173.52 29.6344 173.384 29.6344C169.962 29.6344 166.677 29.6344 163.255 29.6344C158.739 29.7709 155.454 27.7225 152.854 24.0352C152.854 23.8987 152.58 23.6256 152.443 23.489C152.443 23.489 152.443 23.489 152.169 23.3524Z" fill="url(#paint13_linear_13943_20144)"/>
|
||||
<path d="M126.986 32.3656C130.681 32.3656 134.514 32.3656 138.209 32.3656C141.768 32.3656 144.368 34.4141 146.421 37.1454C146.695 37.5551 146.969 37.9648 147.516 38.6476C148.337 37.5551 149.022 36.4626 149.843 35.5066V62C149.432 59.815 149.158 57.7665 148.748 55.5815C146.558 43.5639 139.578 35.9163 127.67 32.6388C127.533 32.6388 127.26 32.6388 127.123 32.5022C127.123 32.5022 127.123 32.5022 127.123 32.3656H126.986Z" fill="url(#paint14_linear_13943_20144)"/>
|
||||
<path d="M151.347 53.8062C151.347 49.8458 151.211 45.8855 151.347 41.9251C151.484 38.6476 153.4 36.1894 156.001 34.2775C156.411 34.0044 156.822 33.7313 157.506 33.185C156.411 32.3656 155.317 31.6828 154.222 31H180.5C178.721 31.2731 177.078 31.6828 175.299 31.9559C168.866 32.9119 163.255 35.5066 158.738 40.1498C155.18 43.7004 152.853 47.9339 151.621 52.8502C151.621 53.1234 151.484 53.3965 151.347 53.8062Z" fill="url(#paint15_linear_13943_20144)"/>
|
||||
<path d="M97.6982 83.8669V69H101.869C103.147 69 104.291 69.2691 105.3 69.8745C106.309 70.4127 107.049 71.2872 107.587 72.2963C108.125 73.3726 108.394 74.5835 108.394 75.9289V76.8707C108.394 78.2834 108.125 79.4943 107.587 80.5706C107.049 81.647 106.309 82.4542 105.3 82.9924C104.291 83.5305 103.147 83.8669 101.802 83.8669H97.6982ZM99.6491 70.6145V82.2524H101.735C103.214 82.2524 104.425 81.7815 105.233 80.8397C106.04 79.8979 106.511 78.5525 106.511 76.8034V75.9289C106.511 74.2471 106.107 72.9017 105.3 72.0272C104.493 71.0854 103.416 70.6145 101.936 70.6145H99.6491Z" fill="#1D1D1A"/>
|
||||
<path d="M154.881 83.8669H152.863L145.396 72.4308V83.8669H143.445V69H145.396L152.863 80.5033V69H154.814V83.8669H154.881Z" fill="#1D1D1A"/>
|
||||
<path d="M193.7 76.938L191.883 78.8216V83.8669H189.933V69H191.883V76.3325L198.476 69H200.831L194.978 75.5253L201.301 83.7996H198.947L193.7 76.938Z" fill="#1D1D1A"/>
|
||||
<path d="M125.92 71.4218L130.494 83.8669H132.445L126.794 69H125.113L119.395 83.8669H121.413L125.92 71.4218Z" fill="#1D1D1A"/>
|
||||
<path d="M172.407 71.4218L176.914 83.8669H178.932L173.282 69H171.533L165.882 83.8669H167.9L172.407 71.4218Z" fill="#1D1D1A"/>
|
||||
<path d="M98.3216 96.7115H97.1582V90.8667H98.3216V96.7115Z" fill="#231F20"/>
|
||||
<path d="M108.503 96.7115H107.339L104.985 92.778V96.7115H103.821V90.8667H104.985L107.339 94.8002V90.8667H108.503V96.7115Z" fill="#231F20"/>
|
||||
<path d="M118.463 91.7808H116.801V96.7392H115.637V91.7808H114.003V90.8667H118.49V91.7808H118.463Z" fill="#231F20"/>
|
||||
<path d="M127.314 94.1354H125.154V95.7974H127.73V96.6838H123.99V90.8667H127.73V91.7808H125.154V93.2489H127.314V94.1631V94.1354Z" fill="#231F20"/>
|
||||
<path d="M134.393 95.8251H137.024V96.7115H133.229V90.8667H134.393V95.8251Z" fill="#231F20"/>
|
||||
<path d="M143.688 95.8251H146.319V96.7115H142.524V90.8667H143.688V95.8251Z" fill="#231F20"/>
|
||||
<path d="M152.983 96.7115H151.819V90.8667H152.983V96.7115Z" fill="#231F20"/>
|
||||
<path d="M163.081 95.9913C162.887 96.2406 162.638 96.4345 162.277 96.6007C161.917 96.7669 161.446 96.8777 160.892 96.8777C160.338 96.8777 159.618 96.6561 159.147 96.2129C158.704 95.7697 158.482 95.188 158.482 94.4678V93.2489C158.482 92.5287 158.704 91.9747 159.147 91.5315C159.59 91.0883 160.144 90.8667 160.837 90.8667C161.529 90.8667 162.111 91.0329 162.471 91.3653C162.859 91.6977 163.025 92.1686 163.025 92.7226H161.917C161.917 92.4179 161.834 92.1686 161.64 92.0024C161.474 91.8362 161.197 91.7531 160.865 91.7531C160.532 91.7531 160.228 91.8916 160.006 92.1686C159.784 92.4456 159.674 92.8057 159.674 93.2489V94.4678C159.674 94.911 159.784 95.2711 160.006 95.5481C160.228 95.8251 160.532 95.9636 160.92 95.9636C161.308 95.9636 161.419 95.9359 161.585 95.8805C161.751 95.8251 161.862 95.742 161.973 95.6589V94.5232H160.837V93.7199H163.136V95.9913H163.081Z" fill="#231F20"/>
|
||||
<path d="M171.96 94.1354H169.799V95.7974H172.375V96.6838H168.636V90.8667H172.375V91.7808H169.799V93.2489H171.96V94.1631V94.1354Z" fill="#231F20"/>
|
||||
<path d="M182.556 96.7115H181.393L179.038 92.778V96.7115H177.875V90.8667H179.038L181.393 94.8002V90.8667H182.556V96.7115Z" fill="#231F20"/>
|
||||
<path d="M192.572 94.8556C192.572 95.4927 192.406 95.9913 191.99 96.3514C191.602 96.7115 191.048 96.8777 190.383 96.8777C189.719 96.8777 189.109 96.6561 188.694 96.2129C188.251 95.7697 188.057 95.188 188.057 94.4678V93.3043C188.057 92.5841 188.278 92.0024 188.694 91.5592C189.109 91.116 189.663 90.8667 190.356 90.8667C191.048 90.8667 191.63 91.0329 192.018 91.393C192.433 91.7531 192.627 92.224 192.6 92.8611H191.464C191.464 92.4733 191.381 92.1963 191.187 92.0301C190.993 91.8362 190.716 91.7531 190.356 91.7531C189.996 91.7531 189.719 91.8916 189.525 92.1686C189.331 92.4456 189.22 92.8057 189.22 93.2766V94.4401C189.22 94.8833 189.331 95.2434 189.525 95.5481C189.719 95.8251 190.023 95.9636 190.383 95.9636C190.744 95.9636 190.993 95.8805 191.159 95.6866C191.325 95.4927 191.408 95.2157 191.408 94.8279H192.544L192.572 94.8556Z" fill="#231F20"/>
|
||||
<path d="M201.427 94.1354H199.266V95.7974H201.842V96.6838H198.103V90.8667H201.842V91.7808H199.266V93.2489H201.427V94.1631V94.1354Z" fill="#231F20"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint10_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint11_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint12_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint13_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint14_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint15_linear_13943_20144" x1="134.924" y1="6.14538" x2="176.565" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,84 @@
|
||||
<svg width="62" height="62" viewBox="0 0 62 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.837 7.64759C29.837 11.8811 29.9738 16.1145 29.837 20.348C29.7001 23.489 27.6471 25.8106 25.1835 27.7225C24.7729 27.9956 24.3623 28.2687 23.678 28.815C24.9098 29.6344 25.8678 30.3172 26.9628 31.1366H0C0.4106 31 0.684334 30.8634 0.821201 30.8634C4.79034 30.0441 9.03321 29.7709 12.8655 28.1322C21.8987 24.3084 27.5102 17.6168 29.7001 8.05728L29.837 7.64759Z" fill="url(#paint0_linear_13943_20143)"/>
|
||||
<path d="M33.6692 23.3524C33.2586 23.8987 32.7112 24.4449 32.4374 24.8546C32.0268 25.4009 31.89 25.9471 31.4794 26.63V0C31.4794 0 31.4794 0 31.6162 0C32.0268 2.04846 32.3006 3.96035 32.7112 6.00881C34.4904 16.3877 40.2388 23.7621 49.9564 27.859C51.5988 28.5419 53.5149 28.9515 55.2942 29.6344C55.1573 29.6344 55.0204 29.6344 54.8836 29.6344C51.4619 29.6344 48.1771 29.6344 44.7554 29.6344C40.2388 29.7709 36.954 27.7225 34.3536 24.0352C34.3536 23.8987 34.0798 23.6256 33.943 23.489C33.943 23.489 33.943 23.489 33.6692 23.3524Z" fill="url(#paint1_linear_13943_20143)"/>
|
||||
<path d="M8.48601 32.3656C12.1814 32.3656 16.0137 32.3656 19.7091 32.3656C23.2676 32.3656 25.8681 34.4141 27.9211 37.1454C28.1948 37.5551 28.4686 37.9648 29.016 38.6476C29.8372 37.5551 30.5216 36.4626 31.3428 35.5066V62C30.9322 59.815 30.6584 57.7665 30.2478 55.5815C28.058 43.5639 21.0778 35.9163 9.17034 32.6388C9.03347 32.6388 8.75974 32.6388 8.62288 32.5022C8.62288 32.5022 8.62288 32.5022 8.62288 32.3656H8.48601Z" fill="url(#paint2_linear_13943_20143)"/>
|
||||
<path d="M32.8474 53.8062C32.8474 49.8458 32.7105 45.8855 32.8474 41.9251C32.9842 38.6476 34.9004 36.1894 37.5008 34.2775C37.9114 34.0044 38.322 33.7313 39.0064 33.185C37.9114 32.3656 36.8165 31.6828 35.7216 31H62C60.2207 31.2731 58.5783 31.6828 56.7991 31.9559C50.3663 32.9119 44.7548 35.5066 40.2382 40.1498C36.6796 43.7004 34.3529 47.9339 33.1211 52.8502C33.1211 53.1234 32.9842 53.3965 32.8474 53.8062Z" fill="url(#paint3_linear_13943_20143)"/>
|
||||
<path d="M29.837 7.64759C29.837 11.8811 29.9738 16.1145 29.837 20.348C29.7001 23.489 27.6471 25.8106 25.1835 27.7225C24.7729 27.9956 24.3623 28.2687 23.678 28.815C24.9098 29.6344 25.8678 30.3172 26.9628 31.1366H0C0.4106 31 0.684334 30.8634 0.821201 30.8634C4.79034 30.0441 9.03321 29.7709 12.8655 28.1322C21.8987 24.3084 27.5102 17.6168 29.7001 8.05728L29.837 7.64759Z" fill="url(#paint4_linear_13943_20143)"/>
|
||||
<path d="M33.6692 23.3524C33.2586 23.8987 32.7112 24.4449 32.4374 24.8546C32.0268 25.4009 31.89 25.9471 31.4794 26.63V0C31.4794 0 31.4794 0 31.6162 0C32.0268 2.04846 32.3006 3.96035 32.7112 6.00881C34.4904 16.3877 40.2388 23.7621 49.9564 27.859C51.5988 28.5419 53.5149 28.9515 55.2942 29.6344C55.1573 29.6344 55.0204 29.6344 54.8836 29.6344C51.4619 29.6344 48.1771 29.6344 44.7554 29.6344C40.2388 29.7709 36.954 27.7225 34.3536 24.0352C34.3536 23.8987 34.0798 23.6256 33.943 23.489C33.943 23.489 33.943 23.489 33.6692 23.3524Z" fill="url(#paint5_linear_13943_20143)"/>
|
||||
<path d="M8.48601 32.3656C12.1814 32.3656 16.0137 32.3656 19.7091 32.3656C23.2676 32.3656 25.8681 34.4141 27.9211 37.1454C28.1948 37.5551 28.4686 37.9648 29.016 38.6476C29.8372 37.5551 30.5216 36.4626 31.3428 35.5066V62C30.9322 59.815 30.6584 57.7665 30.2478 55.5815C28.058 43.5639 21.0778 35.9163 9.17034 32.6388C9.03347 32.6388 8.75974 32.6388 8.62288 32.5022C8.62288 32.5022 8.62288 32.5022 8.62288 32.3656H8.48601Z" fill="url(#paint6_linear_13943_20143)"/>
|
||||
<path d="M32.8474 53.8062C32.8474 49.8458 32.7105 45.8855 32.8474 41.9251C32.9842 38.6476 34.9004 36.1894 37.5008 34.2775C37.9114 34.0044 38.322 33.7313 39.0064 33.185C37.9114 32.3656 36.8165 31.6828 35.7216 31H62C60.2207 31.2731 58.5783 31.6828 56.7991 31.9559C50.3663 32.9119 44.7548 35.5066 40.2382 40.1498C36.6796 43.7004 34.3529 47.9339 33.1211 52.8502C33.1211 53.1234 32.9842 53.3965 32.8474 53.8062Z" fill="url(#paint7_linear_13943_20143)"/>
|
||||
<path d="M29.837 7.64759C29.837 11.8811 29.9738 16.1145 29.837 20.348C29.7001 23.489 27.6471 25.8106 25.1835 27.7225C24.7729 27.9956 24.3623 28.2687 23.678 28.815C24.9098 29.6344 25.8678 30.3172 26.9628 31.1366H0C0.4106 31 0.684334 30.8634 0.821201 30.8634C4.79034 30.0441 9.03321 29.7709 12.8655 28.1322C21.8987 24.3084 27.5102 17.6168 29.7001 8.05728L29.837 7.64759Z" fill="url(#paint8_linear_13943_20143)"/>
|
||||
<path d="M33.6692 23.3524C33.2586 23.8987 32.7112 24.4449 32.4374 24.8546C32.0268 25.4009 31.89 25.9471 31.4794 26.63V0C31.4794 0 31.4794 0 31.6162 0C32.0268 2.04846 32.3006 3.96035 32.7112 6.00881C34.4904 16.3877 40.2388 23.7621 49.9564 27.859C51.5988 28.5419 53.5149 28.9515 55.2942 29.6344C55.1573 29.6344 55.0204 29.6344 54.8836 29.6344C51.4619 29.6344 48.1771 29.6344 44.7554 29.6344C40.2388 29.7709 36.954 27.7225 34.3536 24.0352C34.3536 23.8987 34.0798 23.6256 33.943 23.489C33.943 23.489 33.943 23.489 33.6692 23.3524Z" fill="url(#paint9_linear_13943_20143)"/>
|
||||
<path d="M8.48601 32.3656C12.1814 32.3656 16.0137 32.3656 19.7091 32.3656C23.2676 32.3656 25.8681 34.4141 27.9211 37.1454C28.1948 37.5551 28.4686 37.9648 29.016 38.6476C29.8372 37.5551 30.5216 36.4626 31.3428 35.5066V62C30.9322 59.815 30.6584 57.7665 30.2478 55.5815C28.058 43.5639 21.0778 35.9163 9.17034 32.6388C9.03347 32.6388 8.75974 32.6388 8.62288 32.5022C8.62288 32.5022 8.62288 32.5022 8.62288 32.3656H8.48601Z" fill="url(#paint10_linear_13943_20143)"/>
|
||||
<path d="M32.8474 53.8062C32.8474 49.8458 32.7105 45.8855 32.8474 41.9251C32.9842 38.6476 34.9004 36.1894 37.5008 34.2775C37.9114 34.0044 38.322 33.7313 39.0064 33.185C37.9114 32.3656 36.8165 31.6828 35.7216 31H62C60.2207 31.2731 58.5783 31.6828 56.7991 31.9559C50.3663 32.9119 44.7548 35.5066 40.2382 40.1498C36.6796 43.7004 34.3529 47.9339 33.1211 52.8502C33.1211 53.1234 32.9842 53.3965 32.8474 53.8062Z" fill="url(#paint11_linear_13943_20143)"/>
|
||||
<path d="M29.837 7.64759C29.837 11.8811 29.9738 16.1145 29.837 20.348C29.7001 23.489 27.6471 25.8106 25.1835 27.7225C24.7729 27.9956 24.3623 28.2687 23.678 28.815C24.9098 29.6344 25.8678 30.3172 26.9628 31.1366H0C0.4106 31 0.684334 30.8634 0.821201 30.8634C4.79034 30.0441 9.03321 29.7709 12.8655 28.1322C21.8987 24.3084 27.5102 17.6168 29.7001 8.05728L29.837 7.64759Z" fill="url(#paint12_linear_13943_20143)"/>
|
||||
<path d="M33.6692 23.3524C33.2586 23.8987 32.7112 24.4449 32.4374 24.8546C32.0268 25.4009 31.89 25.9471 31.4794 26.63V0C31.4794 0 31.4794 0 31.6162 0C32.0268 2.04846 32.3006 3.96035 32.7112 6.00881C34.4904 16.3877 40.2388 23.7621 49.9564 27.859C51.5988 28.5419 53.5149 28.9515 55.2942 29.6344C55.1573 29.6344 55.0204 29.6344 54.8836 29.6344C51.4619 29.6344 48.1771 29.6344 44.7554 29.6344C40.2388 29.7709 36.954 27.7225 34.3536 24.0352C34.3536 23.8987 34.0798 23.6256 33.943 23.489C33.943 23.489 33.943 23.489 33.6692 23.3524Z" fill="url(#paint13_linear_13943_20143)"/>
|
||||
<path d="M8.48601 32.3656C12.1814 32.3656 16.0137 32.3656 19.7091 32.3656C23.2676 32.3656 25.8681 34.4141 27.9211 37.1454C28.1948 37.5551 28.4686 37.9648 29.016 38.6476C29.8372 37.5551 30.5216 36.4626 31.3428 35.5066V62C30.9322 59.815 30.6584 57.7665 30.2478 55.5815C28.058 43.5639 21.0778 35.9163 9.17034 32.6388C9.03347 32.6388 8.75974 32.6388 8.62288 32.5022C8.62288 32.5022 8.62288 32.5022 8.62288 32.3656H8.48601Z" fill="url(#paint14_linear_13943_20143)"/>
|
||||
<path d="M32.8474 53.8062C32.8474 49.8458 32.7105 45.8855 32.8474 41.9251C32.9842 38.6476 34.9004 36.1894 37.5008 34.2775C37.9114 34.0044 38.322 33.7313 39.0064 33.185C37.9114 32.3656 36.8165 31.6828 35.7216 31H62C60.2207 31.2731 58.5783 31.6828 56.7991 31.9559C50.3663 32.9119 44.7548 35.5066 40.2382 40.1498C36.6796 43.7004 34.3529 47.9339 33.1211 52.8502C33.1211 53.1234 32.9842 53.3965 32.8474 53.8062Z" fill="url(#paint15_linear_13943_20143)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint10_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint11_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint12_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint13_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint14_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint15_linear_13943_20143" x1="16.424" y1="6.14538" x2="58.0646" y2="53.3515" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B52B9D"/>
|
||||
<stop offset="1" stop-color="#8393CF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -4,6 +4,7 @@ import { type FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Seperator from '@/components/utils/Seperator'
|
||||
import Image from 'next/image'
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface'
|
||||
|
||||
const GamePage: FC = () => {
|
||||
|
||||
@@ -22,7 +23,7 @@ const GamePage: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-11 relative bg-primary text-white dark:bg-primary dark:text-primary-foreground h-[120px] rounded-[10px] p-4'>
|
||||
<div className='mt-11 relative overflow-hidden bg-primary text-white dark:bg-primary dark:text-primary-foreground h-[120px] rounded-[10px] p-4'>
|
||||
<div className='max-w-[99px]'>
|
||||
<div className='font-bold'>My Games</div>
|
||||
<div className='mt-1'>
|
||||
@@ -46,7 +47,7 @@ const GamePage: FC = () => {
|
||||
<div className='mt-8 flex flex-col gap-4'>
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
className={glassSurfaceCard('rounded-[10px] p-2 flex justify-between items-center cursor-pointer')}
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
@@ -76,7 +77,7 @@ const GamePage: FC = () => {
|
||||
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
className={glassSurfaceCard('rounded-[10px] p-2 flex justify-between items-center cursor-pointer')}
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
@@ -106,7 +107,7 @@ const GamePage: FC = () => {
|
||||
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
className={glassSurfaceCard('rounded-[10px] p-2 flex justify-between items-center cursor-pointer')}
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
|
||||
@@ -2,25 +2,15 @@
|
||||
|
||||
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function DialogsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isCheckoutPage = pathname?.includes('/checkout/');
|
||||
|
||||
return (
|
||||
<ClientSideWrapper>
|
||||
<main className={clsx(
|
||||
'h-full dark:bg-background w-full overflow-y-auto noscrollbar pt-4 pb-6 px-6',
|
||||
isCheckoutPage
|
||||
? 'bg-background dark:bg-background'
|
||||
: 'bg-container lg:bg-background'
|
||||
)}>
|
||||
<main className='h-full bg-background dark:bg-background w-full overflow-y-auto noscrollbar pt-4 pb-6 px-6'>
|
||||
{children}
|
||||
</main>
|
||||
</ClientSideWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogsLayout
|
||||
export default DialogsLayout
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGetNotifications, useReadAllNotifications } from './hooks/useNotific
|
||||
import type { Notification } from './types/Types';
|
||||
import { useGetNotificationsCount } from '../../(Main)/hooks/useMenuData';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -110,7 +111,7 @@ export default function NotificationsIndex({ }: Props) {
|
||||
const formattedTime = formatTime(notification.createdAt);
|
||||
|
||||
return (
|
||||
<li key={notification.id} className='bg-container py-5 px-4 rounded-container shadow-lg'>
|
||||
<li key={notification.id} className={glassSurfaceCard('py-5 px-4')}>
|
||||
{/* <h5 className='font-medium text-sm'>{notification.title}</h5> */}
|
||||
<p className='text-xs mt-2 leading-5'>{notification.content}</p>
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { AddressSelectionModal } from './AddressSelectionModal';
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
return address.address;
|
||||
};
|
||||
|
||||
export const AddressSection = () => {
|
||||
@@ -66,7 +67,7 @@ export const AddressSection = () => {
|
||||
// اگر آدرس ندارد
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5 mb-4">{t("SectionAddress.Title")}</h3>
|
||||
<p className='text-sm2 text-disabled-text mb-4'>
|
||||
@@ -89,7 +90,7 @@ export const AddressSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<div className='w-full flex justify-between items-center gap-2'>
|
||||
<h3 className="text-sm2 font-medium leading-5 inline-block">{t("SectionAddress.Title")}</h3>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ef } from '@/lib/helpers/utfNumbers';
|
||||
import { Edit2 } from 'iconsax-react';
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
return address.address;
|
||||
};
|
||||
|
||||
interface AddressSelectionModalProps {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
@@ -42,7 +43,7 @@ export const CarAddressSection = () => {
|
||||
}, [carAddress, setCarAddress]);
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5 mb-4">
|
||||
اطلاعات خودرو
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useApplyCoupon, useRemoveCoupon } from '../../hooks/useOrderData';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
|
||||
|
||||
@@ -73,7 +74,7 @@ export const CouponSection = ({
|
||||
}, [cartItems, onCouponCodeChange]);
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{t("SectionCoupon.Title")}</h3>
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { extractErrorMessage } from "@/lib/func";
|
||||
import { Card, Icon, Wallet2 } from "iconsax-react";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaymentMethodEnum } from "../../enum/Enum";
|
||||
import { useGetPaymentMethod } from "../../hooks/useOrderData";
|
||||
@@ -19,6 +20,11 @@ type PaymentSectionProps = {
|
||||
onPaymentTypeChange: (id: string) => void;
|
||||
};
|
||||
|
||||
type CreditCardOption = {
|
||||
cardNumber: string;
|
||||
cardOwner: string;
|
||||
};
|
||||
|
||||
const getIconByMethod = (method: PaymentMethodEnum): Icon => {
|
||||
if (method === PaymentMethodEnum.Online || method === PaymentMethodEnum.CreditCard) {
|
||||
return Card;
|
||||
@@ -39,6 +45,7 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const { mutateAsync: singleUpload, isPending: isUploading } = useSingleUpload();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedCardNumber, setSelectedCardNumber] = useState<string>("");
|
||||
|
||||
const walletAmount = userWallet?.data?.balance ?? 0;
|
||||
const totalAmount = cartData?.data?.total ?? 0;
|
||||
@@ -68,6 +75,11 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
||||
method: item.method,
|
||||
cardNumber: item.cardNumber,
|
||||
cardOwner: item.cardOwner,
|
||||
cards:
|
||||
item.cards?.map((card) => ({
|
||||
cardNumber: card.cardNumber,
|
||||
cardOwner: card.owner,
|
||||
})) ?? [],
|
||||
description: item.description,
|
||||
isWallet: item.method === PaymentMethodEnum.Wallet,
|
||||
}));
|
||||
@@ -84,11 +96,45 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
||||
if (selected?.method !== PaymentMethodEnum.CreditCard) {
|
||||
setPaymentDesc("");
|
||||
setPaymentAttachments([]);
|
||||
setSelectedCardNumber("");
|
||||
}
|
||||
onPaymentTypeChange(value);
|
||||
setIsSelectedPayment(true);
|
||||
};
|
||||
|
||||
const creditCardOptions = useMemo<CreditCardOption[]>(() => {
|
||||
if (!selectedCreditCard) return [];
|
||||
|
||||
const normalizedCards = selectedCreditCard.cards.filter(
|
||||
(card): card is CreditCardOption => !!card.cardNumber && !!card.cardOwner,
|
||||
);
|
||||
|
||||
if (normalizedCards.length > 0) return normalizedCards;
|
||||
|
||||
if (selectedCreditCard.cardNumber && selectedCreditCard.cardOwner) {
|
||||
return [{ cardNumber: selectedCreditCard.cardNumber, cardOwner: selectedCreditCard.cardOwner }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [selectedCreditCard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creditCardOptions.length === 0) {
|
||||
setSelectedCardNumber("");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelectedCard = creditCardOptions.some((item) => item.cardNumber === selectedCardNumber);
|
||||
if (!hasSelectedCard) {
|
||||
setSelectedCardNumber(creditCardOptions[0].cardNumber);
|
||||
}
|
||||
}, [creditCardOptions, selectedCardNumber]);
|
||||
|
||||
const selectedCard = useMemo(
|
||||
() => creditCardOptions.find((item) => item.cardNumber === selectedCardNumber) ?? null,
|
||||
[creditCardOptions, selectedCardNumber],
|
||||
);
|
||||
|
||||
const handleCopyCardNumber = useCallback(
|
||||
async (cardNumber: string) => {
|
||||
try {
|
||||
@@ -121,7 +167,7 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionPayment.Title")}</h3>
|
||||
|
||||
@@ -146,24 +192,49 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
||||
|
||||
{selectedCreditCard && (
|
||||
<div className="mt-6 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{(selectedCreditCard.cardOwner || selectedCreditCard.cardNumber) && (
|
||||
{creditCardOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{`تعداد کارت های مقصد: ${creditCardOptions.length}`}
|
||||
</p>
|
||||
{creditCardOptions.map((card) => {
|
||||
const isActiveCard = selectedCardNumber === card.cardNumber;
|
||||
return (
|
||||
<button
|
||||
key={card.cardNumber}
|
||||
type="button"
|
||||
onClick={() => setSelectedCardNumber(card.cardNumber)}
|
||||
className={`w-full rounded-normal border px-3 py-2 text-right transition-colors ${
|
||||
isActiveCard ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">{card.cardOwner}</span>
|
||||
<span className="block text-sm font-medium mt-1" dir="ltr">
|
||||
{card.cardNumber}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedCard && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{selectedCreditCard.cardOwner && (
|
||||
{selectedCard.cardOwner && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{tOrderDetail("SectionPayment.CreditCard.CardOwnerLabel")}</span>
|
||||
<p className="text-sm font-medium mt-1">{selectedCreditCard.cardOwner}</p>
|
||||
<p className="text-sm font-medium mt-1">{selectedCard.cardOwner}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedCreditCard.cardNumber && (
|
||||
{selectedCard.cardNumber && (
|
||||
<div className="text-left">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{tOrderDetail("SectionPayment.CreditCard.CardNumberLabel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="block text-sm font-medium mt-1 cursor-pointer hover:text-primary transition-colors"
|
||||
dir="ltr"
|
||||
onClick={() => handleCopyCardNumber(selectedCreditCard.cardNumber!)}
|
||||
onClick={() => handleCopyCardNumber(selectedCard.cardNumber)}
|
||||
>
|
||||
{selectedCreditCard.cardNumber}
|
||||
{selectedCard.cardNumber}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useGetShipmentMethod } from '../../hooks/useOrderData';
|
||||
import { DeliveryMethod } from '../../types/Types';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type ShippingSectionProps = {
|
||||
deliveryType: string;
|
||||
@@ -57,7 +58,7 @@ export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: Shipping
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionShipping.Title")}</h3>
|
||||
<Combobox
|
||||
@@ -80,4 +81,3 @@ export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: Shipping
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { MinusIcon, PlusIcon } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type TableSectionProps = {
|
||||
tableNumber: number;
|
||||
@@ -14,7 +15,7 @@ export const TableSection = ({ tableNumber, onIncrement, onDecrement }: TableSec
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4 py-2">
|
||||
<section className={glassSurfaceCard('p-4 py-2')}>
|
||||
<div className="py-3">
|
||||
<div className='w-full grid grid-cols-2 justify-between items-center gap-2'>
|
||||
<h3 className="text-sm2 font-medium leading-5 inline-block">{t("SectionTable.Title")}</h3>
|
||||
@@ -22,7 +23,7 @@ export const TableSection = ({ tableNumber, onIncrement, onDecrement }: TableSec
|
||||
<div className='w-full flex justify-end'>
|
||||
<motion.div
|
||||
whileTap={{ scale: 1.05 }}
|
||||
className="bg-background active:drop-shadow-xs max-w-[132px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2"
|
||||
className="bg-background pattern-secondary-bg active:drop-shadow-xs max-w-[132px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2"
|
||||
>
|
||||
<button
|
||||
onClick={onIncrement}
|
||||
|
||||
@@ -116,10 +116,21 @@ export interface PaymentMethodResponse {
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
merchantId: string | null;
|
||||
cards?: PaymentMethodCard[];
|
||||
}
|
||||
|
||||
export type PaymentMethodsResponse = BaseResponse<PaymentMethodResponse[]>;
|
||||
|
||||
export interface PaymentMethodCard {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
paymentMethod: string;
|
||||
cardNumber: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export type CheckoutStoreType = {
|
||||
isSelectedAddress: boolean;
|
||||
setIsSelectedAddress: (value: boolean) => void;
|
||||
@@ -196,8 +207,6 @@ export interface OrderAddress {
|
||||
user: OrderUser;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -439,12 +448,10 @@ export interface OrderDetailCarAddress {
|
||||
}
|
||||
|
||||
export interface OrderDetailUserAddress {
|
||||
city: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
fullName: string;
|
||||
latitude: number;
|
||||
province: string;
|
||||
longitude: number;
|
||||
postalCode: string;
|
||||
}
|
||||
@@ -468,6 +475,7 @@ export interface OrderDetail {
|
||||
subTotal: number;
|
||||
tax: number;
|
||||
deliveryFee: number;
|
||||
prepareTime: number | null;
|
||||
total: number;
|
||||
totalItems: number;
|
||||
description: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Clock } from "iconsax-react";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { OrderStatus } from "../../checkout/enum/Enum";
|
||||
import { useCancelOrder, useGetOrderDetail } from "../../checkout/hooks/useOrderData";
|
||||
|
||||
const getDeliveryMethodTitle = (method: string) => {
|
||||
@@ -132,6 +133,14 @@ function OrderTrackingPage() {
|
||||
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center h-16 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">زمان آمادهسازی</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{orderDetail.data.prepareTime
|
||||
? `${orderDetail.data.prepareTime.toLocaleString("fa-IR")} دقیقه`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center h-16 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">نوع ارسال</span>
|
||||
<span className="text-sm font-semibold">{orderDetail.data?.deliveryMethod?.description}</span>
|
||||
@@ -215,7 +224,11 @@ function OrderTrackingPage() {
|
||||
type="submit"
|
||||
onClick={toggleCancelModal}
|
||||
className="bg-disabled! text-foreground!"
|
||||
disabled={orderDetail?.data?.status === "cancelled" || orderDetail?.data?.status === "delivered" || orderDetail?.data?.status === "delivering"}
|
||||
disabled={
|
||||
orderDetail?.data?.status === 'cancelled' ||
|
||||
orderDetail?.data?.status === "delivered" ||
|
||||
orderDetail?.data?.status === "delivering"
|
||||
}
|
||||
>
|
||||
لغو سفارش
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { useVerify } from '../hooks/useVerifyData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { VerifyData } from '../types/Types';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -74,7 +75,7 @@ export default function VerifyIndex({ }: Props) {
|
||||
return (
|
||||
<div className='h-full bg-inherit relative flex flex-col lg:gap-4'>
|
||||
<div className='flex-1 flex flex-col items-center justify-center'>
|
||||
<div className='bg-container py-5 px-2 rounded-container shadow-lg w-full'>
|
||||
<div className={glassSurfaceCard('py-5 px-2 w-full')}>
|
||||
<div className={`items-center gap-2 rounded-2xl h-35 flex flex-col justify-center ${isError ? 'bg-red-50' : 'bg-gray-50'}`}>
|
||||
{isError ? (
|
||||
<CloseCircle className='size-10 stroke-red-500' variant='TwoTone' />
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import ToggleButton, { ToggleButtonClassNames } from "@/components/button/ToggleButton";
|
||||
import TabContainer, { TabContainerClassNames, TabContainerRenderType } from "@/components/tab/TabContainer";
|
||||
import { TabHeader } from "@/components/tab/TabHeader";
|
||||
import { toast, toastLoginRequired } from "@/components/Toast";
|
||||
import RateSelectionBar from "@/components/utils/RateSelectionBar";
|
||||
import ReviewChatThread from "./ReviewChatThread";
|
||||
import { buildReviewThreads } from "../utils/reviewHelpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToken } from "@/lib/api/func";
|
||||
import { extractErrorMessage } from "@/lib/func";
|
||||
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import clsx from "clsx";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetProfile } from "../../../(Profile)/profile/hooks/userProfileData";
|
||||
import { NegativePointEnum, PositivePointEnum } from "../enum/ReviewPoints";
|
||||
import { useCreateReview, useGetFoodReviews } from "../hooks/useFoodData";
|
||||
import { CreateReviewType } from "../types/ReviewTypes";
|
||||
|
||||
type Props = {
|
||||
foodId: string;
|
||||
};
|
||||
|
||||
const positivePointsArray = [
|
||||
PositivePointEnum.GREAT_TASTE,
|
||||
PositivePointEnum.FAST_DELIVERY,
|
||||
PositivePointEnum.GOOD_QUALITY,
|
||||
PositivePointEnum.GOOD_PORTION_SIZE,
|
||||
PositivePointEnum.FRIENDLY_SERVICE,
|
||||
];
|
||||
|
||||
const negativePointsArray = [
|
||||
NegativePointEnum.SMALL_PORTION,
|
||||
NegativePointEnum.SLOW_DELIVERY,
|
||||
NegativePointEnum.POOR_QUALITY,
|
||||
NegativePointEnum.OVERPRICED,
|
||||
NegativePointEnum.UNFRIENDLY_SERVICE,
|
||||
];
|
||||
|
||||
function FoodReviewsSection({ foodId }: Props) {
|
||||
const { t } = useTranslation("rating");
|
||||
const { name } = useParams();
|
||||
const router = useRouter();
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: reviewsData, isLoading: reviewsLoading } = useGetFoodReviews(foodId);
|
||||
const { mutate: createReview, isPending } = useCreateReview(foodId);
|
||||
|
||||
const [rating, setRating] = useState<number>(5);
|
||||
const [comment, setComment] = useState<string>("");
|
||||
const [positivePoints, setPositivePoints] = useState<string[]>([]);
|
||||
const [negativePoints, setNegativePoints] = useState<string[]>([]);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
|
||||
const reviewThreads = buildReviewThreads(reviewsData?.data || []);
|
||||
|
||||
const getTranslationKey = (point: string, isPositive: boolean): string => {
|
||||
const basePath = isPositive ? "Tabs.Strengths.Options" : "Tabs.Weeknesses.Options";
|
||||
return `${basePath}.${point}`;
|
||||
};
|
||||
|
||||
const handleToggle = (pointValue: string, isPositive: boolean) => (checked: boolean) => {
|
||||
const setter = isPositive ? setPositivePoints : setNegativePoints;
|
||||
|
||||
if (checked) {
|
||||
setter((prev) => [...prev, pointValue]);
|
||||
} else {
|
||||
setter((prev) => prev.filter((p) => p !== pointValue));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRatingChange = (value: number[]) => {
|
||||
setRating(value[0]);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setRating(5);
|
||||
setComment("");
|
||||
setPositivePoints([]);
|
||||
setNegativePoints([]);
|
||||
setFormKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const token = await getToken();
|
||||
if (!token || !isSuccess) {
|
||||
toastLoginRequired(() => router.push(`/${name}/auth`), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rating === 0) {
|
||||
toast(t("Errors.RatingRequired") || "لطفا امتیاز خود را انتخاب کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewData: CreateReviewType = {
|
||||
foodId,
|
||||
rating,
|
||||
comment,
|
||||
positivePoints,
|
||||
negativePoints,
|
||||
};
|
||||
|
||||
createReview(reviewData, {
|
||||
onSuccess: () => {
|
||||
toast(t("Success") || "نظر شما با موفقیت ثبت شد", "success");
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error, t("Errors.SubmitFailed") || "خطا در ثبت نظر"), "error");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const negativePointsView = (
|
||||
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
|
||||
{negativePointsArray.map((point) => (
|
||||
<ToggleButton
|
||||
className={{
|
||||
...ToggleButtonClassNames,
|
||||
wrapperActive: "border-red-400",
|
||||
contentActive: "text-red-400",
|
||||
}}
|
||||
key={point}
|
||||
name={`weekness_${point}`}
|
||||
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
|
||||
onToggle={handleToggle(point, false)}
|
||||
>
|
||||
{t(getTranslationKey(point, false))}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const positivePointsView = (
|
||||
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
|
||||
{positivePointsArray.map((point) => (
|
||||
<ToggleButton
|
||||
key={point}
|
||||
name={`strength_${point}`}
|
||||
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
|
||||
onToggle={handleToggle(point, true)}
|
||||
>
|
||||
{t(getTranslationKey(point, true))}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-base font-bold">ثبت نظر</h2>
|
||||
|
||||
<form key={formKey} onSubmit={handleSubmit} className="mt-4">
|
||||
<div className={glassSurfaceFlat("rounded-2xl p-4")}>
|
||||
<RateSelectionBar name="rating" defaultValue={[rating]} onValueChange={handleRatingChange} />
|
||||
|
||||
<div className="mt-8">
|
||||
<TabContainer
|
||||
defaultIndex={1}
|
||||
changeType={TabContainerRenderType.VISIBILITY}
|
||||
className={{
|
||||
...TabContainerClassNames,
|
||||
wrapper: "p-2!",
|
||||
scrollView: clsx(
|
||||
"border-none! rounded-xl! gap-0! h-full bg-[#EAECF0]/80! dark:bg-neutral-900/80! not-dark:gradient-border! grid! grid-cols-2 px-2! py-2! pb-2! w-full overflow-y-hidden justify-center items-center",
|
||||
),
|
||||
title: "text-sm2! font-normal mt-1",
|
||||
header: "rounded-lg h-7! w-full",
|
||||
headerActive: "bg-white dark:bg-neutral-800",
|
||||
titleActive: "text-primary",
|
||||
}}
|
||||
>
|
||||
<TabHeader title={t("Tabs.Weeknesses.Title")} viewRenderer={negativePointsView} />
|
||||
<TabHeader title={t("Tabs.Strengths.Title")} viewRenderer={positivePointsView} />
|
||||
</TabContainer>
|
||||
|
||||
<div className="w-full mt-6">
|
||||
<label htmlFor="foodReviewComment" className="text-sm font-medium">
|
||||
{t("InputComment.Label")}
|
||||
</label>
|
||||
<textarea
|
||||
name="foodReviewComment"
|
||||
id="foodReviewComment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={t("InputComment.Placeholder")}
|
||||
className={glassSurfaceFlat(
|
||||
"mt-2 w-full h-21 px-4 py-2.5 text-xs text-foreground rounded-xl border-0 focus:outline-none focus:ring-2 focus:ring-accent",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="mt-4 w-full rounded-xl font-normal disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isPending ? t("ButtonSubmitting") || "در حال ارسال..." : t("ButtonSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border/60">
|
||||
<h2 className="text-sm2 font-medium leading-5">نظرات کاربران</h2>
|
||||
<div>
|
||||
{reviewsLoading ? (
|
||||
<p className="text-sm2 text-center py-8 text-disabled-text">در حال بارگذاری نظرات...</p>
|
||||
) : reviewThreads.length > 0 ? (
|
||||
reviewThreads.map((thread) => <ReviewChatThread key={thread.root.id} thread={thread} />)
|
||||
) : (
|
||||
<p className="text-sm2 text-center py-8 text-disabled-text">هنوز نظری ثبت نشده است</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default FoodReviewsSection;
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Star1 } from "iconsax-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReviewThread, buildThreadMessages, formatReviewMessageTime } from "../utils/reviewHelpers";
|
||||
|
||||
type Props = {
|
||||
thread: ReviewThread;
|
||||
};
|
||||
|
||||
function ReviewChatThread({ thread }: Props) {
|
||||
const { t } = useTranslation("rating");
|
||||
|
||||
const messages = useMemo(() => buildThreadMessages(thread), [thread]);
|
||||
|
||||
const getTranslationKey = (point: string, isPositive: boolean): string => {
|
||||
const basePath = isPositive ? "Tabs.Strengths.Options" : "Tabs.Weeknesses.Options";
|
||||
return `${basePath}.${point}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="py-4 border-b border-border/60 last:border-b-0">
|
||||
<ul className="flex flex-col gap-4">
|
||||
{messages.map((message) => {
|
||||
const isAdmin = message.type === "admin";
|
||||
|
||||
return (
|
||||
<li key={message.id} className={`flex w-full ${isAdmin ? "justify-start" : "justify-end"}`}>
|
||||
<div
|
||||
className={`w-full max-w-[min(420px,85%)] rounded-[24px] px-5 py-4 ${
|
||||
isAdmin ? "bg-[#F6F7FA] dark:bg-gray-700 rounded-tr-none" : "bg-[#EBEDF5] dark:bg-gray-600 rounded-tl-none"
|
||||
}`}
|
||||
>
|
||||
<h6 className="text-xs font-medium text-gray-700 dark:text-gray-200 mb-2">{message.senderName}</h6>
|
||||
|
||||
{message.parentMessage && (
|
||||
<div className="mb-3 rounded-xl border-r-2 border-primary/50 bg-white/70 dark:bg-black/10 px-3 py-2">
|
||||
<p className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{message.parentMessage.senderName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-3 whitespace-pre-wrap break-words">
|
||||
{message.parentMessage.content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdmin && message.rating !== undefined && message.rating > 0 && (
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Star1 size={16} color="#FFB800" variant="Bold" />
|
||||
<span className="text-sm font-medium">{message.rating}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">از ۵</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm leading-7 text-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words">{message.content}</p>
|
||||
|
||||
{!isAdmin && message.positivePoints && message.positivePoints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{message.positivePoints.map((point, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 border border-green-500 text-green-600 dark:text-green-400 rounded-xl text-xs bg-white dark:bg-transparent"
|
||||
>
|
||||
{t(getTranslationKey(point, true))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdmin && message.negativePoints && message.negativePoints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{message.negativePoints.map((point, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 border border-red-500 text-red-600 dark:text-red-400 rounded-xl text-xs bg-white dark:bg-transparent"
|
||||
>
|
||||
{t(getTranslationKey(point, false))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.createdAt && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<span dir="ltr" className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{formatReviewMessageTime(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReviewChatThread;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/Service";
|
||||
import { CreateReviewType } from "../types/ReviewTypes";
|
||||
|
||||
export const useGetFood = (id: string) => {
|
||||
return useQuery({
|
||||
@@ -13,3 +14,22 @@ export const useToggleFavorite = () => {
|
||||
mutationFn: api.toggleFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetFoodReviews = (foodId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["food-reviews", foodId],
|
||||
queryFn: () => api.getFoodReviews(foodId),
|
||||
enabled: !!foodId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateReview = (foodId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: CreateReviewType) => api.createReview(params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["food-reviews", foodId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,14 +6,15 @@ import PlusIcon from "@/components/icons/PlusIcon";
|
||||
import { toast, toastLoginRequired } from "@/components/Toast";
|
||||
import { getToken } from "@/lib/api/func";
|
||||
import { ef } from "@/lib/helpers/utfNumbers";
|
||||
import clsx from "clsx";
|
||||
import { glassSurface, glassSurfaceCard, glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
|
||||
import { useGetAbout } from "../about/hooks/useAboutData";
|
||||
import { useGetFood, useToggleFavorite } from "./hooks/useFoodData";
|
||||
import FoodReviewsSection from "./components/FoodReviewsSection";
|
||||
|
||||
type Props = object;
|
||||
|
||||
@@ -26,7 +27,7 @@ function FoodPage({}: Props) {
|
||||
const { items, addToCart, removeFromCart } = useCart();
|
||||
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
||||
const { mutate: toggleFavorite } = useToggleFavorite();
|
||||
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const foodId = food?.data?.id || (id as string);
|
||||
const quantity = useMemo(() => {
|
||||
const item = items?.[foodId];
|
||||
@@ -80,6 +81,39 @@ function FoodPage({}: Props) {
|
||||
}
|
||||
}, [food?.data?.isFavorite]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = document.querySelector("main");
|
||||
const imageEl = imageRef.current;
|
||||
if (!scrollEl || !imageEl) return;
|
||||
|
||||
const desktopQuery = window.matchMedia("(min-width: 1024px)");
|
||||
const MAX_SCALE = 1.6;
|
||||
const MIN_SCALE = 1.0;
|
||||
|
||||
const applyParallax = () => {
|
||||
if (desktopQuery.matches) {
|
||||
imageEl.style.transform = "";
|
||||
return;
|
||||
}
|
||||
const scrollRange = imageEl.parentElement?.offsetHeight ?? 320;
|
||||
const progress = Math.min(1, Math.max(0, scrollEl.scrollTop / scrollRange));
|
||||
const scale = MIN_SCALE + (MAX_SCALE - MIN_SCALE) * progress;
|
||||
imageEl.style.transform = `scale(${scale})`;
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", applyParallax, { passive: true });
|
||||
desktopQuery.addEventListener("change", applyParallax);
|
||||
window.addEventListener("resize", applyParallax);
|
||||
applyParallax();
|
||||
|
||||
return () => {
|
||||
scrollEl.removeEventListener("scroll", applyParallax);
|
||||
desktopQuery.removeEventListener("change", applyParallax);
|
||||
window.removeEventListener("resize", applyParallax);
|
||||
imageEl.style.transform = "";
|
||||
};
|
||||
}, [food?.data?.id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -105,18 +139,14 @@ function FoodPage({}: Props) {
|
||||
: Array.isArray(foodData.images) && foodData.images.length > 0
|
||||
? typeof foodData.images[0] === "string"
|
||||
? foodData.images[0]
|
||||
: typeof foodData.images[0] === "object" &&
|
||||
foodData.images[0] !== null &&
|
||||
"url" in foodData.images[0]
|
||||
: typeof foodData.images[0] === "object" && foodData.images[0] !== null && "url" in foodData.images[0]
|
||||
? (foodData.images[0] as { url: string }).url
|
||||
: "/assets/images/no-image.png"
|
||||
: "/assets/images/no-image.png";
|
||||
const categoryName = foodData.category?.title;
|
||||
const prepareTime = foodData.prepareTime || 0;
|
||||
const price = foodData.price || 0;
|
||||
const content = Array.isArray(foodData.content)
|
||||
? foodData.content.join("، ")
|
||||
: foodData.content || foodData.desc || "";
|
||||
const content = Array.isArray(foodData.content) ? foodData.content.join("، ") : foodData.content || foodData.desc || "";
|
||||
|
||||
const handleBackToMenu = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -129,51 +159,40 @@ function FoodPage({}: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"not-lg:max-w-lg not-lg:place-self-center not-lg:w-full not-lg:mb-10 pt-6 not-lg:flex not-lg:flex-col lg:fixed lg:z-10 lg:top-20 xl:top-22 lg:bottom-24 lg:left-5 lg:right-4 xl:right-[285px] lg:grid lg:grid-cols-2 lg:min-h-0 lg:overflow-hidden lg:rounded-2xl",
|
||||
!isAvailable && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full lg:h-full min-h-0 not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1">
|
||||
<div className="not-lg:max-w-lg not-lg:place-self-center not-lg:w-full w-full flex flex-col gap-4 pt-6 lg:pt-8 pb-24 lg:pb-28">
|
||||
<div className="not-lg:flex not-lg:flex-col lg:grid lg:grid-cols-2 lg:min-h-[420px] lg:max-h-[480px] lg:rounded-2xl lg:overflow-hidden shrink-0">
|
||||
<div
|
||||
className={glassSurface(
|
||||
"relative w-full lg:h-full min-h-0 rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1 lg:bg-transparent! lg:border-0! lg:shadow-none! lg:[backdrop-filter:none] not-lg:sticky not-lg:top-6 not-lg:z-0 not-lg:h-[min(50vh,400px)] not-lg:min-h-[280px] not-lg:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="lg:object-cover lg:h-full object-cover bg-[#F2F2F9] w-full"
|
||||
ref={imageRef}
|
||||
className="lg:object-cover not-lg:object-contain lg:h-full object-cover object-top bg-[#F2F2F9] w-full not-lg:absolute not-lg:inset-0 not-lg:h-full not-lg:origin-top not-lg:will-change-transform"
|
||||
src={foodImage}
|
||||
alt={foodName}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = "/assets/images/no-image.png";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
|
||||
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">
|
||||
{categoryName || "-"}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBackToMenu}
|
||||
className="p-2 rounded-full bg-container/40"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={18}
|
||||
className="stroke-primary dark:stroke-foreground"
|
||||
/>
|
||||
<div className="absolute top-4 left-0 z-10 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
|
||||
<div className={glassSurfaceFlat("whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold")}>{categoryName || "-"}</div>
|
||||
<button onClick={handleBackToMenu} className={glassSurfaceFlat("p-2 rounded-full")}>
|
||||
<ArrowLeft size={18} className="stroke-primary dark:stroke-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:h-full lg:min-h-0 bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none lg:overflow-y-auto">
|
||||
<div
|
||||
className={glassSurfaceCard(
|
||||
"relative bg-white/60! px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:h-full lg:min-h-0 w-full overflow-hidden not-lg:z-10 not-lg:-mt-12 not-lg:rounded-t-3xl not-lg:shadow-[0_-10px_30px_rgba(0,0,0,0.08)] lg:rounded-l-none lg:overflow-y-auto rounded-3xl",
|
||||
)}
|
||||
>
|
||||
<div className="w-full inline-flex justify-between items-center shrink-0">
|
||||
<h5 className="text-base font-bold">{foodName}</h5>
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className="p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg"
|
||||
>
|
||||
<Heart
|
||||
variant={isFavorite ? "Bold" : "Outline"}
|
||||
size={24}
|
||||
color="currentColor"
|
||||
className={isFavorite ? "fill-primary dark:fill-foreground" : ""}
|
||||
/>
|
||||
<button onClick={handleToggleFavorite} className="p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg">
|
||||
<Heart variant={isFavorite ? "Bold" : "Outline"} size={24} color="currentColor" className={isFavorite ? "fill-primary dark:fill-foreground" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -181,10 +200,7 @@ function FoodPage({}: Props) {
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={14} className="stroke-disabled-text" />
|
||||
<span className="text-disabled-text">
|
||||
زمان پخت و آماده سازی:{" "}
|
||||
{prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : "-"}
|
||||
</span>
|
||||
<span className="text-disabled-text">زمان پخت و آماده سازی: {prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Cup size={14} className="stroke-disabled-text" />
|
||||
@@ -194,18 +210,9 @@ function FoodPage({}: Props) {
|
||||
<div>۰</div>
|
||||
<span>امتیاز برای هر بار خرید</span>
|
||||
</>
|
||||
) : about?.data?.score?.purchaseScore &&
|
||||
about?.data?.score?.purchaseAmount ? (
|
||||
) : about?.data?.score?.purchaseScore && about?.data?.score?.purchaseAmount ? (
|
||||
<>
|
||||
<div>
|
||||
{ef(
|
||||
Math.round(
|
||||
foodData?.price *
|
||||
(Number(about.data.score.purchaseScore) /
|
||||
Number(about.data.score.purchaseAmount)),
|
||||
).toLocaleString("en-US"),
|
||||
)}
|
||||
</div>
|
||||
<div>{ef(Math.round(foodData?.price * (Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount))).toLocaleString("en-US"))}</div>
|
||||
<span>امتیاز برای هر بار خرید</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -215,9 +222,7 @@ function FoodPage({}: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<TruckTick size={14} className="stroke-disabled-text" />
|
||||
<span className="text-disabled-text">
|
||||
{foodData.pickupServe ? "ارسال با پیک" : "-"}
|
||||
</span>
|
||||
<span className="text-disabled-text">{foodData.pickupServe ? "ارسال با پیک" : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,41 +236,26 @@ function FoodPage({}: Props) {
|
||||
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
|
||||
<motion.div
|
||||
whileTap={isAvailable ? { scale: 1.05 } : undefined}
|
||||
className="bg-background active:drop-shadow-xs max-w-[115px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2 relative overflow-hidden"
|
||||
className="bg-background pattern-secondary-bg active:drop-shadow-xs max-w-[115px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2 relative overflow-hidden"
|
||||
>
|
||||
{quantity <= 0 ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable}
|
||||
className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<PlusIcon />
|
||||
<div className="text-sm2 pt-0.5 font-normal text-foreground">
|
||||
افزودن
|
||||
</div>
|
||||
<button onClick={handleAddToCart} disabled={!isAvailable} className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none">
|
||||
{isAvailable && <PlusIcon />}
|
||||
<div className="text-sm2 pt-0.5 font-normal text-foreground">{isAvailable ? "افزودن" : "عدم موجودی"}</div>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable}
|
||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
className={glassSurfaceFlat("hover:brightness-95 active:brightness-90 rounded-sm p-2 transition-all disabled:pointer-events-none")}
|
||||
>
|
||||
<PlusIcon className="text-foreground" />
|
||||
</button>
|
||||
<motion.div
|
||||
key={quantity}
|
||||
initial={{ scale: 1 }}
|
||||
animate={{ scale: [1.2, 0.95, 1] }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm2 pt-0.5 font-semibold"
|
||||
>
|
||||
<motion.div key={quantity} initial={{ scale: 1 }} animate={{ scale: [1.2, 0.95, 1] }} transition={{ duration: 0.3 }} className="text-sm2 pt-0.5 font-semibold">
|
||||
{quantity}
|
||||
</motion.div>
|
||||
<button
|
||||
onClick={handleRemoveFromCart}
|
||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
|
||||
>
|
||||
<button onClick={handleRemoveFromCart} className={glassSurfaceFlat("hover:brightness-95 active:brightness-90 rounded-sm p-2 transition-all")}>
|
||||
<MinusIcon className="text-foreground" />
|
||||
</button>
|
||||
</>
|
||||
@@ -273,6 +263,11 @@ function FoodPage({}: Props) {
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={glassSurfaceCard("w-full px-6 pt-6 pb-7.5 rounded-3xl bg-white/60! shrink-0")}>
|
||||
<FoodReviewsSection foodId={foodId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { FoodResponse } from "../../types/Types";
|
||||
import { CreateReviewType, FoodReviewsResponse } from "../types/ReviewTypes";
|
||||
|
||||
export const getFood = async (id: string): Promise<FoodResponse> => {
|
||||
const { data } = await api.get<FoodResponse>(`/public/foods/${id}`);
|
||||
@@ -10,3 +11,13 @@ export const toggleFavorite = async (foodId: string) => {
|
||||
const { data } = await api.post(`/public/foods/favorite/${foodId}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getFoodReviews = async (foodId: string): Promise<FoodReviewsResponse> => {
|
||||
const { data } = await api.get<FoodReviewsResponse>(`/public/reviews/${foodId}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createReview = async (params: CreateReviewType) => {
|
||||
const { data } = await api.post("/public/reviews", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BaseResponse } from "../../types/Types";
|
||||
import { Review } from "../../about/types/Types";
|
||||
|
||||
export type CreateReviewType = {
|
||||
foodId: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
positivePoints: string[];
|
||||
negativePoints: string[];
|
||||
};
|
||||
|
||||
export type FoodReviewsResponse = BaseResponse<Review[]>;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { isReviewApproved, Review } from "../../about/types/Types";
|
||||
|
||||
export type ReviewThread = {
|
||||
root: Review;
|
||||
replies: Review[];
|
||||
};
|
||||
|
||||
export function getParentId(parent: Review["parent"]): string | null {
|
||||
if (!parent) return null;
|
||||
if (typeof parent === "string") return parent;
|
||||
if (typeof parent === "object" && "id" in parent) return parent.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPopulatedReview(value: Review["parent"]): value is Review {
|
||||
return Boolean(value && typeof value === "object" && "comment" in value);
|
||||
}
|
||||
|
||||
export function getDisplayName(firstName?: string | null, lastName?: string | null, fallback = "کاربر"): string {
|
||||
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
||||
}
|
||||
|
||||
export function buildReviewThreads(reviews: Review[]): ReviewThread[] {
|
||||
const approved = reviews.filter(isReviewApproved);
|
||||
const topLevel = approved.filter((review) => !getParentId(review.parent));
|
||||
const topLevelById = new Map(topLevel.map((review) => [review.id, review]));
|
||||
|
||||
const repliesByParentId = new Map<string, Review[]>();
|
||||
const orphanReplies: Review[] = [];
|
||||
|
||||
for (const review of approved) {
|
||||
const parentId = getParentId(review.parent);
|
||||
if (!parentId) continue;
|
||||
|
||||
if (topLevelById.has(parentId)) {
|
||||
const list = repliesByParentId.get(parentId) ?? [];
|
||||
list.push(review);
|
||||
repliesByParentId.set(parentId, list);
|
||||
} else {
|
||||
orphanReplies.push(review);
|
||||
}
|
||||
}
|
||||
|
||||
const threads: ReviewThread[] = topLevel.map((root) => ({
|
||||
root,
|
||||
replies: (repliesByParentId.get(root.id) ?? []).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
}));
|
||||
|
||||
for (const reply of orphanReplies) {
|
||||
if (isPopulatedReview(reply.parent)) {
|
||||
threads.push({
|
||||
root: reply.parent,
|
||||
replies: [reply],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return threads.sort((a, b) => new Date(b.root.createdAt).getTime() - new Date(a.root.createdAt).getTime());
|
||||
}
|
||||
|
||||
export function resolveParentReview(reply: Review, threadRoot: Review): Review | null {
|
||||
if (isPopulatedReview(reply.parent)) {
|
||||
return reply.parent;
|
||||
}
|
||||
|
||||
const parentId = getParentId(reply.parent);
|
||||
if (parentId && parentId === threadRoot.id) {
|
||||
return threadRoot;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reviewToUserMessage(review: Review): ChatMessage {
|
||||
return {
|
||||
id: review.id,
|
||||
type: "user",
|
||||
senderName: getDisplayName(review.user?.firstName, review.user?.lastName),
|
||||
content: review.comment || "نظری ثبت نشده است",
|
||||
createdAt: review.createdAt,
|
||||
rating: review.rating,
|
||||
positivePoints: review.positivePoints,
|
||||
negativePoints: review.negativePoints,
|
||||
};
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
type: "user" | "admin";
|
||||
senderName: string;
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
rating?: number;
|
||||
positivePoints?: string[];
|
||||
negativePoints?: string[];
|
||||
parentMessage?: {
|
||||
senderName: string;
|
||||
content: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildThreadMessages(thread: ReviewThread): ChatMessage[] {
|
||||
const rootMessage = reviewToUserMessage(thread.root);
|
||||
const items: ChatMessage[] = [rootMessage];
|
||||
|
||||
thread.replies.forEach((reply) => {
|
||||
const parentReview = resolveParentReview(reply, thread.root);
|
||||
const parentMessage = parentReview
|
||||
? {
|
||||
senderName: getDisplayName(parentReview.user?.firstName, parentReview.user?.lastName),
|
||||
content: parentReview.comment || "-",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
items.push({
|
||||
id: reply.id,
|
||||
type: "admin",
|
||||
senderName: getDisplayName(reply.admin?.firstName, reply.admin?.lastName, "رستوران"),
|
||||
content: reply.comment || "-",
|
||||
createdAt: reply.createdAt,
|
||||
parentMessage,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function formatReviewMessageTime(date?: string) {
|
||||
if (!date) return "";
|
||||
|
||||
return new Date(date).toLocaleString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -1,34 +1,32 @@
|
||||
'use client'
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import EqualizerIcon from '@/components/icons/EqualizerIcon';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon';
|
||||
import TabContainer from '@/components/tab/TabContainer';
|
||||
import { TabHeader } from '@/components/tab/TabHeader';
|
||||
import Comment from '@/components/utils/Comment';
|
||||
import RateBar from '@/components/utils/RateBar';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { ArrowDown2, CallCalling, Clock, Gallery, InfoCircle, Instagram, Location, Star1, Whatsapp } from 'iconsax-react';
|
||||
import { useQueryState } from 'next-usequerystate';
|
||||
import Image from 'next/image';
|
||||
import React from 'react'
|
||||
import { useGetAbout, useGetReviews, useGetSchedules } from './hooks/useAboutData';
|
||||
import AboutSkeleton from './components/AboutSkeleton';
|
||||
"use client";
|
||||
import AnimatedBottomSheet from "@/components/bottomsheet/AnimatedBottomSheet";
|
||||
import EqualizerIcon from "@/components/icons/EqualizerIcon";
|
||||
import TelegramIcon from "@/components/icons/TelegramIcon";
|
||||
import TabContainer from "@/components/tab/TabContainer";
|
||||
import { TabHeader } from "@/components/tab/TabHeader";
|
||||
import Comment from "@/components/utils/Comment";
|
||||
import RateBar from "@/components/utils/RateBar";
|
||||
import useToggle from "@/hooks/helpers/useToggle";
|
||||
import { glassSurfaceCard, glassSurfaceCardFlat, glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import { ArrowDown2, CallCalling, Clock, Gallery, InfoCircle, Instagram, Location, Star1, Whatsapp } from "iconsax-react";
|
||||
import { useQueryState } from "next-usequerystate";
|
||||
import Image from "next/image";
|
||||
import AboutSkeleton from "./components/AboutSkeleton";
|
||||
import { useGetAbout, useGetReviews, useGetSchedules } from "./hooks/useAboutData";
|
||||
import { isReviewApproved } from "./types/Types";
|
||||
|
||||
const sortings = [
|
||||
'جدیدترین',
|
||||
'قدیمی ترین'
|
||||
]
|
||||
const sortings = ["جدیدترین", "قدیمی ترین"];
|
||||
|
||||
function AboutPage() {
|
||||
|
||||
const { data: aboutData, isLoading: aboutLoading } = useGetAbout();
|
||||
const { data: reviewsData, isLoading: reviewsLoading } = useGetReviews();
|
||||
const { data: schedulesData, isLoading: schedulesLoading } = useGetSchedules();
|
||||
const [sorting, setSorting] = useQueryState('sortBy', { defaultValue: '0' });
|
||||
const [sorting, setSorting] = useQueryState("sortBy", { defaultValue: "0" });
|
||||
const { state: sortingModal, toggle: toggleSortingModal, set: setSortingModal } = useToggle();
|
||||
|
||||
const restaurant = aboutData?.data;
|
||||
const schedules = schedulesData?.data || [];
|
||||
const phoneList = Array.isArray(restaurant?.phones) ? restaurant.phones.filter((phone): phone is string => Boolean(phone)) : restaurant?.phone ? [restaurant.phone] : [];
|
||||
|
||||
const isLoading = aboutLoading || reviewsLoading || schedulesLoading;
|
||||
|
||||
@@ -39,22 +37,22 @@ function AboutPage() {
|
||||
const changeSorting = (index: number) => {
|
||||
setSorting(() => String(index));
|
||||
setSortingModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: string): string => {
|
||||
return time.substring(0, 5);
|
||||
};
|
||||
|
||||
const getDayName = (weekDay: number): string => {
|
||||
const days = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||
return days[weekDay] || '';
|
||||
const days = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
|
||||
return days[weekDay] || "";
|
||||
};
|
||||
|
||||
const groupSchedulesByDay = () => {
|
||||
const grouped: Record<number, typeof schedules> = {};
|
||||
schedules
|
||||
.filter(schedule => schedule.isActive)
|
||||
.forEach(schedule => {
|
||||
.filter((schedule) => schedule.isActive)
|
||||
.forEach((schedule) => {
|
||||
if (!grouped[schedule.weekDay]) {
|
||||
grouped[schedule.weekDay] = [];
|
||||
}
|
||||
@@ -65,9 +63,7 @@ function AboutPage() {
|
||||
|
||||
const getTodaySchedule = () => {
|
||||
const today = new Date().getDay();
|
||||
const todaySchedules = schedules.filter(schedule =>
|
||||
schedule.isActive && schedule.weekDay === today
|
||||
);
|
||||
const todaySchedules = schedules.filter((schedule) => schedule.isActive && schedule.weekDay === today);
|
||||
if (todaySchedules.length > 0) {
|
||||
const firstSchedule = todaySchedules[0];
|
||||
return `${formatTime(firstSchedule.openTime)} تا ${formatTime(firstSchedule.closeTime)}`;
|
||||
@@ -79,102 +75,90 @@ function AboutPage() {
|
||||
if (!restaurant) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-title" className='py-4'>
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
|
||||
<section aria-labelledby="about-title" className="py-4">
|
||||
<section className={glassSurfaceCard("p-4")}>
|
||||
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-6.25">
|
||||
<div className="">
|
||||
<h2 className='text-sm2 font-bold leading-5'>{restaurant.name}</h2>
|
||||
{restaurant.establishedYear && (
|
||||
<p className="text-sm2 leading-5 mt-4">تاسیس: {restaurant.establishedYear}</p>
|
||||
)}
|
||||
{restaurant.tagNames && restaurant.tagNames.length > 0 && (
|
||||
<p className="text-sm2 leading-5 mt-2">نوع محصولات: {restaurant.tagNames.join('، ')}</p>
|
||||
)}
|
||||
<h2 className="text-sm2 font-bold leading-5">{restaurant.name}</h2>
|
||||
{restaurant.establishedYear && <p className="text-sm2 leading-5 mt-4">تاسیس: {restaurant.establishedYear}</p>}
|
||||
{restaurant.tagNames && restaurant.tagNames.length > 0 && <p className="text-sm2 leading-5 mt-2">نوع محصولات: {restaurant.tagNames.join("، ")}</p>}
|
||||
</div>
|
||||
{restaurant.logo && (
|
||||
<div className="rounded-normal overflow-clip">
|
||||
<Image
|
||||
alt='logo'
|
||||
src={restaurant.logo}
|
||||
width={88}
|
||||
height={88}
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
<Image alt="logo" src={restaurant.logo} width={88} height={88} unoptimized priority />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{restaurant.description && (
|
||||
<div className="mt-[23px]">
|
||||
<div className="mt-5.75">
|
||||
<h3 className="text-sm2 font-bold leading-5">درباره مجموعه</h3>
|
||||
<p className="text-sm2 leading-5 mt-3 border-spacing-48" style={{ wordSpacing: '0.01em' }}>
|
||||
<p className="text-sm2 leading-5 mt-3 border-spacing-48" style={{ wordSpacing: "0.01em" }}>
|
||||
{restaurant.description}
|
||||
</p>
|
||||
{restaurant.images && restaurant.images.length > 0 && (
|
||||
<div className='inline-flex gap-2 mt-[23px] items-center'>
|
||||
<Gallery size={20} className='stroke-disabled-text' />
|
||||
<span className='text-sm2 text-disabled-text font-medium pt-0.5'>عکس های رستوران</span>
|
||||
<div className="inline-flex gap-2 mt-5.75 items-center">
|
||||
<Gallery size={20} className="stroke-disabled-text" />
|
||||
<span className="text-sm2 text-disabled-text font-medium pt-0.5">عکس های رستوران</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{(restaurant.phone || restaurant.telegram || restaurant.whatsapp || restaurant.instagram) && (
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center">
|
||||
<h2 className='text-sm2 font-medium leading-5'>ارتباط</h2>
|
||||
<div className='col-span-1 text-center flex justify-center gap-4'>
|
||||
{restaurant.phone &&
|
||||
<a href={`tel://${restaurant.phone}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
||||
<CallCalling className='stroke-foreground' size={24} />
|
||||
{(phoneList.length > 0 || restaurant.telegram || restaurant.whatsapp || restaurant.instagram) && (
|
||||
<section className={glassSurfaceCard("pt-3 pb-3.5 px-4 mt-4 grid grid-cols-3 items-center")}>
|
||||
<h2 className="text-sm2 font-medium leading-5">ارتباط</h2>
|
||||
<div className="col-span-2 text-center flex justify-center flex-wrap gap-2">
|
||||
{phoneList.map((phone) => (
|
||||
<a key={phone} href={`tel://${phone}`} className="bg-[#EAEDF5] dark:bg-neutral-700 px-2 py-1.5 rounded-normal inline-flex items-center gap-1.5">
|
||||
<CallCalling className="stroke-foreground" size={24} />
|
||||
<span dir="ltr" className="text-xs leading-5">
|
||||
{phone}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
{restaurant.telegram &&
|
||||
<a href={`https://t.me/${restaurant.telegram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
||||
<TelegramIcon className='stroke-foreground' width={24} height={24} />
|
||||
))}
|
||||
{restaurant.telegram && (
|
||||
<a href={`https://t.me/${restaurant.telegram}`} className="bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal">
|
||||
<TelegramIcon className="stroke-foreground" width={24} height={24} />
|
||||
</a>
|
||||
}
|
||||
{restaurant.whatsapp &&
|
||||
<a href={`https://whatsapp.com/?phone=${restaurant.whatsapp}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
||||
<Whatsapp className='stroke-foreground' size={24} />
|
||||
)}
|
||||
{restaurant.whatsapp && (
|
||||
<a href={`https://whatsapp.com/?phone=${restaurant.whatsapp}`} className="bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal">
|
||||
<Whatsapp className="stroke-foreground" size={24} />
|
||||
</a>
|
||||
}
|
||||
{restaurant.instagram &&
|
||||
<a href={`https://instagram.com/${restaurant.instagram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
||||
<Instagram className='stroke-foreground' size={24} />
|
||||
)}
|
||||
{restaurant.instagram && (
|
||||
<a href={`https://instagram.com/${restaurant.instagram}`} className="bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal">
|
||||
<Instagram className="stroke-foreground" size={24} />
|
||||
</a>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{restaurant.address && (
|
||||
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4">
|
||||
<h2 className='text-sm2 font-medium leading-5'>آدرس</h2>
|
||||
<p className='text-sm2 mt-[9px] leading-5'>{restaurant.address}</p>
|
||||
<section className={glassSurfaceCard("px-4 pt-6 pb-6 mt-4")}>
|
||||
<h2 className="text-sm2 font-medium leading-5">آدرس</h2>
|
||||
<p className="text-sm2 mt-2.25 leading-5">{restaurant.address}</p>
|
||||
{restaurant.latitude && restaurant.longitude && (
|
||||
<div className='inline-flex gap-2 mt-[23px] items-center '>
|
||||
<Location size={20} className='stroke-disabled-text' />
|
||||
<span className='text-sm2 text-disabled-text '>موقعیت روی نقشه</span>
|
||||
<div className="inline-flex gap-2 mt-5.75 items-center ">
|
||||
<Location size={20} className="stroke-disabled-text" />
|
||||
<span className="text-sm2 text-disabled-text ">موقعیت روی نقشه</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{schedules.length > 0 && getTodaySchedule() && (
|
||||
<section
|
||||
aria-label='ساعات کاری'
|
||||
className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
|
||||
<section aria-label="ساعات کاری" className={glassSurfaceCard("py-6 px-4 mt-4 flex justify-between items-center")}>
|
||||
<div className="flex items-center gap-2 justify-start">
|
||||
<Clock size={16} className='stroke-disabled-text' />
|
||||
<div className='text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]'>باز</div>
|
||||
<div className='size-1.5 bg-[#D9D9D9] rounded-full'></div>
|
||||
<div className='text-sm2 mt-0.5'>امروز از ساعت {getTodaySchedule()}</div>
|
||||
<Clock size={16} className="stroke-disabled-text" />
|
||||
<div className="text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]">باز</div>
|
||||
<div className="size-1.5 bg-[#D9D9D9] rounded-full"></div>
|
||||
<div className="text-sm2 mt-0.5">امروز از ساعت {getTodaySchedule()}</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<ArrowDown2 size={16} className='stroke-[#292D32]' />
|
||||
<ArrowDown2 size={16} className="stroke-[#292D32]" />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -184,18 +168,13 @@ function AboutPage() {
|
||||
{Object.entries(groupSchedulesByDay())
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([weekDay, daySchedules]) => (
|
||||
<div
|
||||
key={weekDay}
|
||||
style={{ boxShadow: '0px 0px 14px 0px #0000000F' }}
|
||||
className="bg-[#F6F6FA] dark:bg-border rounded-container leading-5 py-6 px-4 mt-4 flex justify-between items-center">
|
||||
<div className="text-sm2">
|
||||
{getDayName(Number(weekDay))}
|
||||
</div>
|
||||
<div className='text-sm2 font-light'>
|
||||
<div key={weekDay} className={glassSurfaceCardFlat("leading-5 py-6 px-4 mt-4 flex justify-between items-center")}>
|
||||
<div className="text-sm2">{getDayName(Number(weekDay))}</div>
|
||||
<div className="text-sm2 font-light">
|
||||
{daySchedules.map((schedule, index) => (
|
||||
<span key={schedule.id}>
|
||||
{formatTime(schedule.openTime)} - {formatTime(schedule.closeTime)}
|
||||
{index < daySchedules.length - 1 && '، '}
|
||||
{index < daySchedules.length - 1 && "، "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -204,23 +183,23 @@ function AboutPage() {
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
return date.toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const secondTab = () => {
|
||||
const reviews = (reviewsData?.data || []).filter(review => review.isApproved);
|
||||
const reviews = (reviewsData?.data || []).filter(isReviewApproved);
|
||||
|
||||
const sortedReviews = [...reviews].sort((a, b) => {
|
||||
if (sorting === '0') {
|
||||
if (sorting === "0") {
|
||||
// جدیدترین
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
} else {
|
||||
@@ -229,16 +208,14 @@ function AboutPage() {
|
||||
}
|
||||
});
|
||||
|
||||
const averageRating = reviews.length > 0
|
||||
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
|
||||
: 0;
|
||||
const averageRating = reviews.length > 0 ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length : 0;
|
||||
|
||||
const ratingCounts = {
|
||||
5: reviews.filter(r => r.rating === 5).length,
|
||||
4: reviews.filter(r => r.rating === 4).length,
|
||||
3: reviews.filter(r => r.rating === 3).length,
|
||||
2: reviews.filter(r => r.rating === 2).length,
|
||||
1: reviews.filter(r => r.rating === 1).length,
|
||||
5: reviews.filter((r) => r.rating === 5).length,
|
||||
4: reviews.filter((r) => r.rating === 4).length,
|
||||
3: reviews.filter((r) => r.rating === 3).length,
|
||||
2: reviews.filter((r) => r.rating === 2).length,
|
||||
1: reviews.filter((r) => r.rating === 1).length,
|
||||
};
|
||||
|
||||
const totalReviews = reviews.length;
|
||||
@@ -251,27 +228,23 @@ function AboutPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className='py-4'>
|
||||
<section
|
||||
aria-label='امتیاز کاربران'
|
||||
className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center">
|
||||
<div className="text-center font-bold text-5xl">
|
||||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<section aria-labelledby="reviews-title" className="py-4">
|
||||
<section aria-label="امتیاز کاربران" className={glassSurfaceCard("p-4 py-6 grid grid-cols-2 items-center")}>
|
||||
<div className="text-center font-bold text-5xl">{averageRating.toFixed(1)}</div>
|
||||
<div className="flex flex-col w-fit items-end">
|
||||
<RateBar className='mt-1' content='5' percentage={String(ratingPercentages[5])} />
|
||||
<RateBar className='mt-1' content='4' percentage={String(ratingPercentages[4])} />
|
||||
<RateBar className='mt-1' content='3' percentage={String(ratingPercentages[3])} />
|
||||
<RateBar className='mt-1' content='2' percentage={String(ratingPercentages[2])} />
|
||||
<RateBar className='mt-1' content='1' percentage={String(ratingPercentages[1])} />
|
||||
<RateBar className="mt-1" content="5" percentage={String(ratingPercentages[5])} />
|
||||
<RateBar className="mt-1" content="4" percentage={String(ratingPercentages[4])} />
|
||||
<RateBar className="mt-1" content="3" percentage={String(ratingPercentages[3])} />
|
||||
<RateBar className="mt-1" content="2" percentage={String(ratingPercentages[2])} />
|
||||
<RateBar className="mt-1" content="1" percentage={String(ratingPercentages[1])} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4">
|
||||
<section className={glassSurfaceCard("pt-3 pb-3.5 px-4 mt-4")}>
|
||||
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
|
||||
<h2 className='text-sm2 font-medium leading-5'>نظرات کاربران</h2>
|
||||
<button onClick={toggleSortingModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
|
||||
<EqualizerIcon className='dark:text-white' />
|
||||
<h2 className="text-sm2 font-medium leading-5">نظرات کاربران</h2>
|
||||
<button onClick={toggleSortingModal} className={glassSurfaceFlat("rounded-xl h-8 pattern-secondary-bg ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-1.75")}>
|
||||
<EqualizerIcon className="dark:text-white" />
|
||||
<span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -280,8 +253,8 @@ function AboutPage() {
|
||||
sortedReviews.map((review) => (
|
||||
<article key={review.id}>
|
||||
<Comment
|
||||
className='pt-8'
|
||||
user={review.user?.firstName + ' ' + review.user?.lastName}
|
||||
className="pt-8"
|
||||
user={review.user?.firstName + " " + review.user?.lastName}
|
||||
rating={review.rating}
|
||||
date={formatDate(review.createdAt)}
|
||||
text={review.comment}
|
||||
@@ -292,7 +265,7 @@ function AboutPage() {
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className='text-sm2 text-center py-8 text-disabled-text'>هنوز نظری ثبت نشده است</p>
|
||||
<p className="text-sm2 text-center py-8 text-disabled-text">هنوز نظری ثبت نشده است</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -306,29 +279,22 @@ function AboutPage() {
|
||||
</div>
|
||||
{i < sortings.length - 1 && <hr className="border-white/40 dark:border-border mb-4 mt-4" />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AnimatedBottomSheet>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='pt-8 h-full overflow-y-auto noscrollbar'>
|
||||
<div className="pt-8 h-full overflow-y-auto noscrollbar">
|
||||
<TabContainer>
|
||||
<TabHeader
|
||||
viewRenderer={firstTab()}
|
||||
title='درباره ما' icon={<InfoCircle size={24} />}>
|
||||
</TabHeader>
|
||||
<TabHeader
|
||||
viewRenderer={secondTab()}
|
||||
title='امتیازات و نظرات' icon={<Star1 size={24} />}>
|
||||
</TabHeader>
|
||||
<TabHeader viewRenderer={firstTab()} title="درباره ما" icon={<InfoCircle size={24} />}></TabHeader>
|
||||
<TabHeader viewRenderer={secondTab()} title="امتیازات و نظرات" icon={<Star1 size={24} />}></TabHeader>
|
||||
</TabContainer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default AboutPage
|
||||
export default AboutPage;
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import TabContainer from "@/components/tab/TabContainer";
|
||||
import { TabHeader } from "@/components/tab/TabHeader";
|
||||
import { InfoCircle, Star1 } from 'iconsax-react';
|
||||
import { glassSurfaceCard, glassSurfaceCardFlat } from '@/lib/styles/glassSurface';
|
||||
|
||||
const AboutSkeleton = () => {
|
||||
const firstTabSkeleton = () => (
|
||||
<section className='py-4'>
|
||||
{/* اطلاعات رستوران */}
|
||||
<section className="bg-container rounded-container shadow-container p-4">
|
||||
<section className={glassSurfaceCard('p-4')}>
|
||||
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-6 w-32 rounded-md mb-4" />
|
||||
@@ -31,7 +32,7 @@ const AboutSkeleton = () => {
|
||||
</section>
|
||||
|
||||
{/* ارتباط */}
|
||||
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center">
|
||||
<section className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center')}>
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
<div className='col-span-1 text-center flex justify-center gap-4'>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
@@ -41,7 +42,7 @@ const AboutSkeleton = () => {
|
||||
</section>
|
||||
|
||||
{/* آدرس */}
|
||||
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4">
|
||||
<section className={glassSurfaceCard('px-4 pt-6 pb-6 mt-4')}>
|
||||
<Skeleton className="h-5 w-12 rounded-md mb-[9px]" />
|
||||
<Skeleton className="h-4 w-full rounded-md mb-2" />
|
||||
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
|
||||
@@ -52,7 +53,7 @@ const AboutSkeleton = () => {
|
||||
</section>
|
||||
|
||||
{/* ساعات کاری امروز */}
|
||||
<section className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
|
||||
<section className={glassSurfaceCard('py-6 px-4 mt-4 flex justify-between items-center')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
<Skeleton className="h-5 w-8 rounded-md" />
|
||||
@@ -66,7 +67,7 @@ const AboutSkeleton = () => {
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-[#F6F6FA] dark:bg-border rounded-container leading-5 py-6 px-4 mt-4 flex justify-between items-center">
|
||||
className={glassSurfaceCardFlat('leading-5 py-6 px-4 mt-4 flex justify-between items-center')}>
|
||||
<Skeleton className="h-5 w-20 rounded-md" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
@@ -77,7 +78,7 @@ const AboutSkeleton = () => {
|
||||
const secondTabSkeleton = () => (
|
||||
<section className='py-4'>
|
||||
{/* امتیاز کاربران */}
|
||||
<section className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center">
|
||||
<section className={glassSurfaceCard('p-4 py-6 grid grid-cols-2 items-center')}>
|
||||
<Skeleton className="h-16 w-20 rounded-md mx-auto" />
|
||||
<div className="flex flex-col w-fit items-end gap-1">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
@@ -87,7 +88,7 @@ const AboutSkeleton = () => {
|
||||
</section>
|
||||
|
||||
{/* نظرات کاربران */}
|
||||
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4">
|
||||
<section className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4')}>
|
||||
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
<Skeleton className="h-8 w-28 rounded-xl" />
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/AboutService";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import * as api from "../service/AboutService";
|
||||
import {
|
||||
getPersistedAboutData,
|
||||
setPersistedAboutData,
|
||||
} from "@/lib/helpers/themeCache";
|
||||
|
||||
export const useGetAbout = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["about", name],
|
||||
queryFn: () => api.getAbout(name),
|
||||
queryFn: async () => {
|
||||
const data = await api.getAbout(name);
|
||||
setPersistedAboutData(name, data);
|
||||
return data;
|
||||
},
|
||||
enabled: !!name,
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
staleTime: 60 * 60_000,
|
||||
gcTime: 24 * 60 * 60_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: () => (name ? getPersistedAboutData(name) : undefined),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (name && query.data) {
|
||||
setPersistedAboutData(name, query.data);
|
||||
}
|
||||
}, [name, query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useGetReviews = () => {
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface Restaurant {
|
||||
isActive: boolean;
|
||||
establishedYear: number | null;
|
||||
phoneNumber: string | null;
|
||||
phone: string;
|
||||
phone?: string | null;
|
||||
phones?: string[];
|
||||
instagram: string | null;
|
||||
telegram: string | null;
|
||||
whatsapp: string | null;
|
||||
@@ -44,55 +45,73 @@ export interface Restaurant {
|
||||
vat: number;
|
||||
domain: string;
|
||||
score: Score;
|
||||
plan: 'base' | 'premium';
|
||||
plan: "base" | "premium";
|
||||
enableAiChat?: boolean;
|
||||
bgType?: "pattern" | "custom" | "color";
|
||||
bgBlur?: number;
|
||||
bgOpacity?: number;
|
||||
bgOverlay?: string;
|
||||
bgUrl?: string;
|
||||
}
|
||||
|
||||
export interface ReviewFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
category: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
restaurant?: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
desc: string | null;
|
||||
content: string | null;
|
||||
price: number;
|
||||
points: number | null;
|
||||
order: number | null;
|
||||
prepareTime: number | null;
|
||||
sat: boolean;
|
||||
sun: boolean;
|
||||
mon: boolean;
|
||||
breakfast: boolean;
|
||||
noon: boolean;
|
||||
dinner: boolean;
|
||||
stock: number;
|
||||
stockDefault: number;
|
||||
isActive: boolean;
|
||||
images: string[];
|
||||
inPlaceServe: boolean;
|
||||
pickupServe: boolean;
|
||||
rate: number;
|
||||
discount: number;
|
||||
isSpecialOffer: boolean;
|
||||
desc?: string | null;
|
||||
content?: string[] | string | null;
|
||||
price?: number;
|
||||
points?: number | null;
|
||||
order?: number | null;
|
||||
prepareTime?: number | null;
|
||||
weekDays?: number[];
|
||||
mealTypes?: string[];
|
||||
sat?: boolean;
|
||||
sun?: boolean;
|
||||
mon?: boolean;
|
||||
breakfast?: boolean;
|
||||
noon?: boolean;
|
||||
dinner?: boolean;
|
||||
stock?: number;
|
||||
stockDefault?: number;
|
||||
isActive?: boolean;
|
||||
images?: string[];
|
||||
inPlaceServe?: boolean;
|
||||
pickupServe?: boolean;
|
||||
rate?: number;
|
||||
score?: number;
|
||||
discount?: number;
|
||||
isSpecialOffer?: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewUser {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
referrer: string | null;
|
||||
isActive: boolean;
|
||||
gender: boolean;
|
||||
wallet: number;
|
||||
points: number;
|
||||
phone: string;
|
||||
birthDate?: string;
|
||||
marriageDate?: string;
|
||||
referrer?: string | null;
|
||||
isActive?: boolean;
|
||||
gender?: boolean;
|
||||
wallet?: number;
|
||||
points?: number;
|
||||
phone?: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "approved" | "pending" | "rejected";
|
||||
|
||||
export interface ReviewAdmin {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
@@ -100,14 +119,26 @@ export interface Review {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
order: string;
|
||||
isBuyer?: boolean;
|
||||
order?: string;
|
||||
food: ReviewFood;
|
||||
user: ReviewUser;
|
||||
user?: ReviewUser | null;
|
||||
admin?: ReviewAdmin | null;
|
||||
comment: string;
|
||||
rating: number;
|
||||
positivePoints: string[];
|
||||
negativePoints: string[];
|
||||
isApproved: boolean;
|
||||
status?: ReviewStatus;
|
||||
parent?: string | { id: string } | Review | null;
|
||||
/** @deprecated use status */
|
||||
isApproved?: boolean;
|
||||
}
|
||||
|
||||
export function isReviewApproved(review: Review): boolean {
|
||||
if (review.status) {
|
||||
return review.status === "approved";
|
||||
}
|
||||
return review.isApproved === true;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||
import type { Food } from "@/app/[name]/(Main)/types/Types";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import MenuItem from "@/components/listview/MenuItem";
|
||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||
import { toast } from "@/components/Toast";
|
||||
import { extractErrorMessage } from "@/lib/func";
|
||||
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import { ArrowLeft, Send2 } from "iconsax-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat } from "./hooks/useAiData";
|
||||
import { AI_SUGGESTIONS } from "./suggestions";
|
||||
import type { ChatFood } from "./types/Types";
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
foods?: ChatFood[];
|
||||
};
|
||||
|
||||
function getGreetingKey() {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 14) return "noon";
|
||||
if (hour >= 14 && hour < 17) return "afternoon";
|
||||
return "evening";
|
||||
}
|
||||
|
||||
function toFood(food: ChatFood): Food {
|
||||
return {
|
||||
id: food.id,
|
||||
category: "",
|
||||
title: food.title,
|
||||
desc: food.desc,
|
||||
content: food.content ?? [],
|
||||
price: food.price,
|
||||
prepareTime: 0,
|
||||
isActive: true,
|
||||
images: food.images ?? [],
|
||||
inPlaceServe: true,
|
||||
pickupServe: true,
|
||||
discount: food.discount ?? 0,
|
||||
isSpecialOffer: food.isSpecialOffer ?? false,
|
||||
isAvailable: true,
|
||||
unavailabilityMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function AiPage() {
|
||||
const { t } = useTranslation("common", { keyPrefix: "AI" });
|
||||
const router = useRouter();
|
||||
const { data: about } = useGetAbout();
|
||||
const { data: profile, isSuccess } = useGetProfile();
|
||||
const { mutate: chat, isPending } = useChat();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const restaurant = about?.data;
|
||||
const brandName = restaurant?.name || t("BrandFallback");
|
||||
const userName = isSuccess && profile?.data?.firstName ? profile.data.firstName : t("GuestName");
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages, isPending]);
|
||||
|
||||
const sendMessage = (text: string) => {
|
||||
const content = text.trim();
|
||||
if (!content || isPending) return;
|
||||
|
||||
const userMessage: Message = { id: `u-${Date.now()}`, role: "user", content };
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
|
||||
chat(
|
||||
{ question: content },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `a-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: res.data.answer,
|
||||
foods: res.data.foods?.length ? res.data.foods : undefined,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error, t("ErrorFallback")), "error");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-[calc(100svh-6.5rem)] flex-col pt-4 xl:h-[calc(100svh-6rem)]">
|
||||
<div className="grid shrink-0 grid-cols-3 items-center">
|
||||
<span />
|
||||
<h1 className="place-self-center text-sm2 font-medium">{t("Heading")}</h1>
|
||||
<ArrowLeft className="cursor-pointer place-self-end" size="20" color="currentColor" onClick={() => router.back()} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col">
|
||||
{messages.length > 0 ? (
|
||||
<ul ref={listRef} className="noscrollbar flex-1 space-y-3 overflow-y-auto pb-4">
|
||||
{messages.map((message) => {
|
||||
const isUser = message.role === "user";
|
||||
const hasFoods = !isUser && !!message.foods?.length;
|
||||
|
||||
return (
|
||||
<li key={message.id} className={`flex flex-col gap-2 ${isUser ? "items-end" : "items-start"}`}>
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? "max-w-[80%] rounded-[20px] rounded-br-none bg-primary/15 px-4 py-3 dark:bg-primary/25"
|
||||
: "max-w-[80%] rounded-[20px] rounded-bl-none bg-[#EBEDF5] px-4 py-3 dark:bg-gray-600"
|
||||
}
|
||||
>
|
||||
<p className="text-xs2 leading-5 whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
|
||||
{hasFoods && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{message.foods!.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={toFood(food)} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{isPending && (
|
||||
<li className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-[20px] rounded-bl-none bg-[#EBEDF5] px-4 py-3 dark:bg-gray-600">
|
||||
<p className="animate-pulse text-xs2 leading-5 text-disabled-text">{t("Thinking")}</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Image src={"/assets/images/logo_ai.svg"} alt={brandName} width={299} height={299} className="w-74.75 h-auto" unoptimized />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-center gap-4">
|
||||
<h2 className="text-base font-medium">{t(`Greeting.${getGreetingKey()}`, { name: userName })}</h2>
|
||||
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{AI_SUGGESTIONS.map(({ labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={labelKey}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => sendMessage(t(labelKey))}
|
||||
className={glassSurfaceFlat("inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 transition-[filter] active:brightness-95 disabled:opacity-50")}
|
||||
>
|
||||
<Icon size={15} className="stroke-foreground" variant="Bold" />
|
||||
<span className="text-xs2">{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className={glassSurfaceFlat("mt-3 flex h-12 shrink-0 items-center gap-1 rounded-xl border border-border px-3")}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t("InputPlaceholder")}
|
||||
autoComplete="off"
|
||||
disabled={isPending}
|
||||
className="h-full w-full bg-transparent text-[11px]! outline-none placeholder:text-disabled-text disabled:opacity-60"
|
||||
/>
|
||||
|
||||
<button type="submit" disabled={!input.trim() || isPending} className="rounded-full p-1.5 disabled:opacity-40">
|
||||
<Send2 size={20} variant="Bold" className="fill-primary text-primary dark:fill-foreground dark:text-foreground" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/AiService";
|
||||
|
||||
export const useChat = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.chat,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
'use client'
|
||||
|
||||
export { default } from './AiPage'
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { ChatRequest, ChatResponse } from "../types/Types";
|
||||
|
||||
export const chat = async (params: ChatRequest) => {
|
||||
const { data } = await api.post<ChatResponse>("/public/ai/chat", params);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Icon } from 'iconsax-react'
|
||||
import {
|
||||
Apple,
|
||||
Cake,
|
||||
Chart,
|
||||
FavoriteChart,
|
||||
Flash,
|
||||
Health,
|
||||
LikeTag,
|
||||
People,
|
||||
Timer,
|
||||
} from 'iconsax-react'
|
||||
|
||||
export type Suggestion = {
|
||||
labelKey: string
|
||||
icon: Icon
|
||||
}
|
||||
|
||||
export const AI_SUGGESTIONS: Suggestion[] = [
|
||||
{ labelKey: 'Suggestions.vegetarian', icon: Apple },
|
||||
{ labelKey: 'Suggestions.healthy', icon: Health },
|
||||
{ labelKey: 'Suggestions.tasty', icon: LikeTag },
|
||||
{ labelKey: 'Suggestions.protein', icon: FavoriteChart },
|
||||
{ labelKey: 'Suggestions.bestsellers', icon: Chart },
|
||||
{ labelKey: 'Suggestions.spicy', icon: Flash },
|
||||
{ labelKey: 'Suggestions.quick', icon: Timer },
|
||||
{ labelKey: 'Suggestions.dessert', icon: Cake },
|
||||
{ labelKey: 'Suggestions.group', icon: People },
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export type ChatRequest = {
|
||||
question: string;
|
||||
};
|
||||
|
||||
export type ChatFood = {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
content: string[] | null;
|
||||
price: number;
|
||||
images: string[];
|
||||
discount: number;
|
||||
isSpecialOffer: boolean;
|
||||
};
|
||||
|
||||
export type ChatData = {
|
||||
answer: string;
|
||||
foods?: ChatFood[];
|
||||
consumedTokens: number;
|
||||
cost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
};
|
||||
|
||||
export type ChatResponse = BaseResponse<ChatData>;
|
||||
@@ -16,6 +16,7 @@ import CartSkeleton from "./components/CartSkeleton";
|
||||
import CartSummary from "./components/CartSummary";
|
||||
import { useCart } from "./hook/useCart";
|
||||
import { useCartPageState } from "./hooks/useCartPageState";
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
|
||||
const CartPage = () => {
|
||||
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
||||
@@ -42,7 +43,7 @@ const CartPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full noscrollbar flex flex-col bg-background">
|
||||
<div className="overflow-y-auto h-full noscrollbar flex flex-col">
|
||||
<div className="grid grid-cols-3 items-center mt-6">
|
||||
{isCartEmpty || isLoading ? (
|
||||
<span></span>
|
||||
@@ -93,7 +94,7 @@ const CartPage = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pb-24">
|
||||
{!isPremium && (
|
||||
<div className="bg-container rounded-container p-4 border border-border shadow-sm">
|
||||
<div className={glassSurfaceCard('p-4')}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<NotificationBellIcon
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import { useCart } from '../hook/useCart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useGetCartItems } from '../hooks/useCartData';
|
||||
import { useCartStore } from '../store/Store';
|
||||
import { guestCartItemsToFoods } from '../lib/cartUtils';
|
||||
import { useGetFoods } from "@/app/[name]/(Main)/hooks/useMenuData";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import Button from "@/components/button/PrimaryButton";
|
||||
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCart } from "../hook/useCart";
|
||||
import { useGetCartItems } from "../hooks/useCartData";
|
||||
import { guestCartItemsToFoods } from "../lib/cartUtils";
|
||||
import { useCartStore } from "../store/Store";
|
||||
|
||||
interface CartSummaryProps {
|
||||
isPremium: boolean;
|
||||
@@ -26,7 +27,7 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
||||
const { data: foodsResponse } = useGetFoods();
|
||||
const { items } = useCart();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
||||
const { description, setDescription } = useCartStore();
|
||||
|
||||
const totalPrice = React.useMemo(() => {
|
||||
@@ -45,10 +46,7 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
||||
}, 0);
|
||||
}, [isSuccess, cartData?.data?.total, foodsResponse?.data, items]);
|
||||
|
||||
const formatPrice = React.useCallback(
|
||||
(value: number) => value.toLocaleString('fa-IR'),
|
||||
[]
|
||||
);
|
||||
const formatPrice = React.useCallback((value: number) => value.toLocaleString("fa-IR"), []);
|
||||
|
||||
if (!hasItems) {
|
||||
return null;
|
||||
@@ -57,36 +55,32 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
||||
return (
|
||||
<>
|
||||
{isPremium && (
|
||||
<div className='mt-2 mb-4'>
|
||||
<div className='relative'>
|
||||
<h3 className='text-sm'>{t('InputDescription.Label')}</h3>
|
||||
<div className="mt-2 mb-4">
|
||||
<div className="relative">
|
||||
<h3 className="text-sm">{t("InputDescription.Label")}</h3>
|
||||
<textarea
|
||||
className='w-full px-4 py-2.5 mt-4 text-sm2 leading-6 outline-0 border border-border rounded-normal resize-none focus:ring-0'
|
||||
placeholder={t('InputDescription.Placeholder')}
|
||||
id='description'
|
||||
name='description'
|
||||
className="w-full px-4 py-2.5 mt-4 text-sm2 leading-6 outline-0 border border-white rounded-normal bg-white/50 resize-none focus:ring-0"
|
||||
placeholder={t("InputDescription.Placeholder")}
|
||||
id="description"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
></textarea>
|
||||
<span className='absolute top-4 right-2 px-2 bg-background text-foreground text-xs'></span>
|
||||
<span className="absolute top-4 right-2 px-2 bg-background text-foreground text-xs"></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-container border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div>
|
||||
{isPremium && (
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
||||
{formatPrice(totalPrice)} تومان
|
||||
</div>
|
||||
)}
|
||||
<div className={glassSurfaceNav("fixed bottom-0 left-0 right-0 z-50 border-t border-border p-4 w-full")}>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-xs text-gray-400 dark:text-disabled-text">{t("PayableAmountLabel")}</div>
|
||||
{isPremium && <div className="text-sm mt-2 font-semibold dark:text-foreground">{formatPrice(totalPrice)} تومان</div>}
|
||||
</div>
|
||||
|
||||
{isPremium && (
|
||||
<Link
|
||||
href={'order/checkout/0'}
|
||||
href={"order/checkout/0"}
|
||||
onClick={(event) => {
|
||||
if (!isSuccess) {
|
||||
event.preventDefault();
|
||||
@@ -96,16 +90,10 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button className='w-fit! px-10 dark:bg-white dark:text-black! dark:hover:bg-gray-100'>
|
||||
{t('ButtonCheckout')}
|
||||
</Button>
|
||||
<Button className="w-fit! px-10 dark:bg-white dark:text-black! dark:hover:bg-gray-100">{t("ButtonCheckout")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isPremium && (
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
||||
{formatPrice(totalPrice)} تومان
|
||||
</div>
|
||||
)}
|
||||
{!isPremium && <div className="text-sm mt-2 font-semibold dark:text-foreground">{formatPrice(totalPrice)} تومان</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ef } from '@/lib/helpers/utfNumbers'
|
||||
import { EmojiHappy, Microphone2, Paperclip2 } from 'iconsax-react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface'
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -64,7 +65,7 @@ function ChatIndex ({}: Props) {
|
||||
<section className='flex flex-col h-full pt-6'>
|
||||
<h1 className='font-medium'>علی مصلحی</h1>
|
||||
|
||||
<div className='bg-container rounded-[30px] flex flex-col justify-between mt-6 flex-1 px-6 pt-7 pb-8 overflow-hidden'>
|
||||
<div className={glassSurfaceCard('rounded-[30px] flex flex-col justify-between mt-6 flex-1 px-6 pt-7 pb-8 overflow-hidden')}>
|
||||
<div className='overflow-y-auto flex flex-col-reverse'>
|
||||
<ul className='grid gap-6 pb-6 '>
|
||||
{messagesMemo.map((v, i) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useToggle from '@/hooks/helpers/useToggle'
|
||||
import Prompt from '@/components/utils/Prompt'
|
||||
import { glassSurfaceFlat } from '@/lib/styles/glassSurface'
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -170,7 +171,7 @@ function ChatIndex ({}: Props) {
|
||||
{selectedChats.length > 0 ? (
|
||||
<span
|
||||
onClick={toggleDeleteModal}
|
||||
className='bg-container p-2 rounded-xl active:bg-gray-50 dark:active:bg-neutral-500'
|
||||
className={glassSurfaceFlat('p-2 rounded-xl active:brightness-95 cursor-pointer')}
|
||||
>
|
||||
<Trash
|
||||
size={24}
|
||||
@@ -180,7 +181,7 @@ function ChatIndex ({}: Props) {
|
||||
) : (
|
||||
<Link
|
||||
href={'chat/nearby'}
|
||||
className='bg-container p-2 rounded-xl active:bg-gray-50 dark:active:bg-neutral-500'
|
||||
className={glassSurfaceFlat('p-2 rounded-xl active:brightness-95 cursor-pointer')}
|
||||
>
|
||||
<Radar2
|
||||
size={24}
|
||||
@@ -198,7 +199,7 @@ function ChatIndex ({}: Props) {
|
||||
<li
|
||||
key={i}
|
||||
{...longPress(chat.id)}
|
||||
className='select-none flex items-center gap-x-2 hover:brightness-105 cursor-default active:brightness-102 p-2 bg-background rounded-xl'
|
||||
className={glassSurfaceFlat('select-none flex items-center gap-x-2 hover:brightness-105 cursor-default active:brightness-102 p-2 rounded-xl')}
|
||||
>
|
||||
<div className='size-[49px] min-w-[49px]'>
|
||||
{selectedChats.includes(chat.id) ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Category } from "@/app/[name]/(Main)/types/Types";
|
||||
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
|
||||
import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer";
|
||||
import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
|
||||
import { GLASS_SURFACE_DISABLED, GLASS_SURFACE_SELECTED } from "@/lib/styles/glassSurface";
|
||||
import clsx from "clsx";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -19,14 +20,14 @@ const SVG_MASK_STYLE = {
|
||||
WebkitMaskPosition: "center",
|
||||
} as const;
|
||||
|
||||
function CategoryImage({ src, size, alt, tintWithPrimary = true }: { src: string; size: number; alt: string; tintWithPrimary?: boolean }) {
|
||||
function CategoryImage({ src, size, alt, tintWithPrimary = true, disabled = false }: { src: string; size: number; alt: string; tintWithPrimary?: boolean; disabled?: boolean }) {
|
||||
const isSvg = src.endsWith(".svg");
|
||||
const shouldUseSvgMask = isSvg && tintWithPrimary;
|
||||
|
||||
if (shouldUseSvgMask) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 bg-primary"
|
||||
className={clsx("shrink-0 bg-primary", disabled && "opacity-40")}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -48,7 +49,7 @@ function CategoryImage({ src, size, alt, tintWithPrimary = true }: { src: string
|
||||
height={size}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="shrink-0 object-contain"
|
||||
className={clsx("shrink-0 object-contain", disabled && "opacity-40")}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = "/assets/images/food-image.png";
|
||||
}}
|
||||
@@ -108,11 +109,17 @@ const CategoryScroll = ({ categories, selectedCategory, onSelect, variant = "lar
|
||||
</Renderer> */}
|
||||
{categories.map((item) => {
|
||||
const isSelected = item.id === selectedCategory;
|
||||
const isDisabled = !item.isActive;
|
||||
|
||||
return (
|
||||
<Renderer key={item.id} className={clsx(isSelected && "bg-container!")} onClick={handleSelect(item.id)}>
|
||||
<CategoryImage src={item.avatarUrl || "/assets/images/food-image.png"} size={imageSize} alt="category image" tintWithPrimary={!usesColoredSvg} />
|
||||
<span className="text-xs text-foreground text-center">{item.title}</span>
|
||||
<Renderer
|
||||
key={item.id}
|
||||
className={clsx(!isSelected && GLASS_SURFACE_DISABLED, isDisabled && "cursor-not-allowed", isSelected && !isDisabled && GLASS_SURFACE_SELECTED)}
|
||||
onClick={isDisabled ? undefined : handleSelect(item.id)}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
<CategoryImage src={item.avatarUrl || "/assets/images/food-image.png"} size={imageSize} alt="category image" tintWithPrimary={!usesColoredSvg} disabled={isDisabled} />
|
||||
<span className={clsx("text-xs text-center whitespace-nowrap", isDisabled ? "text-disabled-text" : "text-foreground")}>{item.title}</span>
|
||||
</Renderer>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import type { Slider } from "@/app/[name]/(Main)/types/Types";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
sliders: Slider[];
|
||||
};
|
||||
|
||||
const AUTOPLAY_MS = 8000;
|
||||
|
||||
function getVisibleSliders(sliders: Slider[]) {
|
||||
return sliders
|
||||
.filter((slider) => slider.isActive !== false && slider.imageUrl)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
function SliderSlide({
|
||||
slider,
|
||||
priority,
|
||||
}: {
|
||||
slider: Slider;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const image = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={slider.imageUrl}
|
||||
alt={slider.title || "promo banner"}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = "/assets/images/food-image.png";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const slideContent = (
|
||||
<div className="relative aspect-[2.4/1] w-full overflow-hidden rounded-2xl">
|
||||
{image}
|
||||
{slider.title ? (
|
||||
<div className="pointer-events-none absolute bottom-6 right-3 max-w-[75%]">
|
||||
<p className="truncate text-right text-sm font-medium text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.7)]">
|
||||
{slider.title}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!slider.link) {
|
||||
return slideContent;
|
||||
}
|
||||
|
||||
const isExternal = /^https?:\/\//i.test(slider.link);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={slider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full shrink-0 snap-center"
|
||||
aria-label={slider.title || "promo banner"}
|
||||
>
|
||||
{slideContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={slider.link}
|
||||
className="block w-full shrink-0 snap-center"
|
||||
aria-label={slider.title || "promo banner"}
|
||||
>
|
||||
{slideContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeSliderCarousel({ sliders }: Props) {
|
||||
const visibleSliders = useMemo(() => getVisibleSliders(sliders), [sliders]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const autoplayPausedRef = useRef(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
slideRefs.current[index]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "start",
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container || visibleSliders.length <= 1) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const slideWidth = container.clientWidth;
|
||||
if (!slideWidth) return;
|
||||
|
||||
const nextIndex = Math.round(container.scrollLeft / slideWidth);
|
||||
setActiveIndex(Math.min(nextIndex, visibleSliders.length - 1));
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [visibleSliders.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSliders.length <= 1) return;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (autoplayPausedRef.current) return;
|
||||
|
||||
setActiveIndex((currentIndex) => {
|
||||
const nextIndex = (currentIndex + 1) % visibleSliders.length;
|
||||
scrollToIndex(nextIndex);
|
||||
return nextIndex;
|
||||
});
|
||||
}, AUTOPLAY_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [scrollToIndex, visibleSliders.length]);
|
||||
|
||||
const pauseAutoplay = useCallback(() => {
|
||||
autoplayPausedRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
autoplayPausedRef.current = false;
|
||||
}, AUTOPLAY_MS * 2);
|
||||
}, []);
|
||||
|
||||
if (visibleSliders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-1 w-full" aria-label="promotional banners">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
dir="ltr"
|
||||
className="flex w-full snap-x snap-mandatory overflow-x-auto noscrollbar touch-pan-x"
|
||||
onTouchStart={pauseAutoplay}
|
||||
onMouseDown={pauseAutoplay}
|
||||
>
|
||||
{visibleSliders.map((slider, index) => (
|
||||
<div
|
||||
key={slider.id}
|
||||
ref={(element) => {
|
||||
slideRefs.current[index] = element;
|
||||
}}
|
||||
className="w-full shrink-0 snap-start"
|
||||
>
|
||||
<SliderSlide slider={slider} priority={index === 0} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visibleSliders.length > 1 ? (
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
{visibleSliders.map((slider, index) => (
|
||||
<button
|
||||
key={slider.id}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
aria-current={index === activeIndex}
|
||||
onClick={() => {
|
||||
pauseAutoplay();
|
||||
setActiveIndex(index);
|
||||
scrollToIndex(index);
|
||||
}}
|
||||
className={clsx(
|
||||
"h-1.5 rounded-full transition-all duration-200",
|
||||
index === activeIndex ? "w-5 bg-primary" : "w-1.5 bg-foreground/25",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
|
||||
import HomeSliderCarousel from "@/app/[name]/(Main)/components/HomeSliderCarousel";
|
||||
import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
|
||||
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
||||
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
||||
@@ -9,24 +10,27 @@ import MenuItem from "@/components/listview/MenuItem";
|
||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||
import useToggle from "@/hooks/helpers/useToggle";
|
||||
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import { Candle2 } from "iconsax-react";
|
||||
import { useQueryState } from "next-usequerystate";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetCategories, useGetFoods } from "../hooks/useMenuData";
|
||||
import { useGetCategories, useGetFoods, useGetSliders } from "../hooks/useMenuData";
|
||||
import type { Category, Food } from "../types/Types";
|
||||
|
||||
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
||||
|
||||
const MenuIndex = () => {
|
||||
const { data: foodsData, isLoading: foodsLoading } = useGetFoods();
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories();
|
||||
const { data: foodsData, isPending: foodsPending } = useGetFoods();
|
||||
const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
|
||||
const { data: slidersData } = useGetSliders();
|
||||
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
||||
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
|
||||
const sliders = useMemo(() => slidersData?.data ?? [], [slidersData?.data]);
|
||||
|
||||
const isLoading = foodsLoading || categoriesLoading;
|
||||
const isLoading = foodsPending || categoriesPending;
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
|
||||
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
|
||||
@@ -161,11 +165,17 @@ const MenuIndex = () => {
|
||||
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
||||
|
||||
if (isLoading) {
|
||||
return <MenuSkeleton />;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center pt-5 mb-8">
|
||||
<HomeSliderCarousel sliders={sliders} />
|
||||
<MenuSkeleton embedded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
|
||||
<HomeSliderCarousel sliders={sliders} />
|
||||
<div className="w-full">
|
||||
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
|
||||
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
|
||||
@@ -173,13 +183,13 @@ const MenuIndex = () => {
|
||||
|
||||
<section className="w-full">
|
||||
<div className="flex flex-wrap gap-2 items-center relative" ref={smallCategoriesRef}>
|
||||
<span className="sm:text-base text-sm font-medium max-w-[113px] sm:max-w-auto">{selectedCategory === "0" ? "" : categories.find((c) => c.id === selectedCategory)?.title || ""}</span>
|
||||
<div className="flex min-w-[247px] flex-1 gap-2 justify-end items-center">
|
||||
<button onClick={toggleFilterModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
|
||||
<span className="sm:text-base text-sm font-medium max-w-28.25 sm:max-w-auto">{selectedCategory === "0" ? "" : categories.find((c) => c.id === selectedCategory)?.title || ""}</span>
|
||||
<div className="flex min-w-61.75 flex-1 gap-2 justify-end items-center">
|
||||
<button onClick={toggleFilterModal} className={glassSurfaceFlat("rounded-xl h-8 ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-1.75")}>
|
||||
<Candle2 className="stroke-foreground" size={16} />
|
||||
<span className="text-xs leading-5 font-medium whitespace-nowrap">{tMenu("MenuFilterDrawer.Label")}</span>
|
||||
</button>
|
||||
<button onClick={toggleSortingModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center gap-2">
|
||||
<button onClick={toggleSortingModal} className={glassSurfaceFlat("rounded-xl h-8 ps-4 pe-2 py-1.5 inline-flex items-center gap-2")}>
|
||||
<TextAlignIcon className="text-foreground" />
|
||||
<span className="text-xs leading-5 font-medium whitespace-nowrap">{activeSortingLabel}</span>
|
||||
</button>
|
||||
@@ -196,7 +206,7 @@ const MenuIndex = () => {
|
||||
opacity: smallCategoriesVisible ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
className={clsx("fixed left-0 z-10 top-0 px-4 pt-16 bg-[#F4F5F9CC] dark:bg-background/70 backdrop-blur-[44px] right-0 xl:pr-72 xl:pt-20", !smallCategoriesVisible && "pointer-events-none")}
|
||||
className={clsx(glassSurfaceFlat("fixed left-0 z-10 top-0 px-4 pt-16 right-0 xl:pr-72 xl:pt-20 border-x-0 border-t-0 rounded-none"), !smallCategoriesVisible && "pointer-events-none")}
|
||||
>
|
||||
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} variant="small" />
|
||||
</motion.div>
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import clsx from "clsx";
|
||||
|
||||
const MenuSkeleton = () => {
|
||||
type Props = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
const MenuSkeleton = ({ embedded = false }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center pt-5 mb-8">
|
||||
<div className={clsx("flex flex-col gap-4 items-center w-full", !embedded && "pt-5 mb-8")}>
|
||||
<div className="w-full">
|
||||
<Skeleton className="h-12 w-full rounded-xl mb-4" />
|
||||
<div className="flex gap-3 overflow-x-hidden py-4">
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import * as api from "../service/MenuService";
|
||||
import { useParams } from "next/navigation";
|
||||
import { hasAuthToken } from "@/lib/api/func";
|
||||
import {
|
||||
getPersistedCategoriesData,
|
||||
getPersistedMenuData,
|
||||
setPersistedCategoriesData,
|
||||
setPersistedMenuData,
|
||||
} from "@/lib/helpers/themeCache";
|
||||
|
||||
export const useGetFoods = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["menu", name],
|
||||
queryFn: () => api.getFoods(name),
|
||||
queryFn: async () => {
|
||||
const data = await api.getFoods(name);
|
||||
setPersistedMenuData(name, data);
|
||||
return data;
|
||||
},
|
||||
enabled: !!name,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnMount: "always",
|
||||
placeholderData: () => (name ? getPersistedMenuData(name) : undefined),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (name && query.data) {
|
||||
setPersistedMenuData(name, query.data);
|
||||
}
|
||||
}, [name, query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useGetCategories = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["categories", name],
|
||||
queryFn: () => api.getCategories(name),
|
||||
queryFn: async () => {
|
||||
const data = await api.getCategories(name);
|
||||
setPersistedCategoriesData(name, data);
|
||||
return data;
|
||||
},
|
||||
enabled: !!name,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnMount: "always",
|
||||
placeholderData: () => (name ? getPersistedCategoriesData(name) : undefined),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (name && query.data) {
|
||||
setPersistedCategoriesData(name, query.data);
|
||||
}
|
||||
}, [name, query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useGetNotificationsCount = () => {
|
||||
@@ -32,3 +67,14 @@ export const useGetNotificationsCount = () => {
|
||||
enabled: hasAuthToken(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetSliders = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return useQuery({
|
||||
queryKey: ["sliders", name],
|
||||
queryFn: () => api.getSliders(name),
|
||||
enabled: !!name,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
|
||||
import React from 'react'
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<LoadingOverlay />
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
@@ -12,7 +12,7 @@ import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetOrders } from './hooks/useHistoryData';
|
||||
import { HistoryOrderItem } from './types/Types';
|
||||
import { OrderStatus } from './enum/Enum';
|
||||
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||
|
||||
const fallbackImage = '/assets/images/food-preview.png';
|
||||
|
||||
@@ -80,7 +80,7 @@ function OrdersIndex() {
|
||||
const items: HistoryOrderItem[] = order.items || [];
|
||||
|
||||
return (
|
||||
<div key={order.id} className='w-full px-[17px] mb-4 pt-8 pb-6 bg-container rounded-3xl'>
|
||||
<div key={order.id} className={glassSurface('w-full px-[17px] mb-4 pt-8 pb-6 rounded-3xl')}>
|
||||
<div className="text-sm2">
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
@@ -165,7 +165,7 @@ function OrdersIndex() {
|
||||
const items: HistoryOrderItem[] = order.items || [];
|
||||
|
||||
return (
|
||||
<div key={order.id} className='w-full px-[17px] mb-4 pt-8 pb-6 bg-container rounded-3xl'>
|
||||
<div key={order.id} className={glassSurface('w-full px-[17px] mb-4 pt-8 pb-6 rounded-3xl')}>
|
||||
<div className="text-sm2">
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
@@ -220,11 +220,6 @@ function OrdersIndex() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{order.status === OrderStatus.COMPLETED && (
|
||||
<Link href={`rate/${order.id}?foodId=${item.food.id}`}>
|
||||
<Button className='text-xs px-3 py-2 h-auto'>{t('Card.Rate')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -79,12 +79,10 @@ export interface HistoryOrderDeliveryMethod {
|
||||
}
|
||||
|
||||
export interface HistoryOrderUserAddress {
|
||||
city: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
fullName: string;
|
||||
latitude: number;
|
||||
province: string;
|
||||
longitude: number;
|
||||
postalCode: string;
|
||||
}
|
||||
@@ -97,8 +95,6 @@ export interface HistoryOrderAddress {
|
||||
user: string;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import ToggleButton, { ToggleButtonClassNames } from '@/components/button/ToggleButton';
|
||||
import TabContainer, { TabContainerClassNames, TabContainerRenderType } from '@/components/tab/TabContainer';
|
||||
import { TabHeader } from '@/components/tab/TabHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import RateSelectionBar from '@/components/utils/RateSelectionBar';
|
||||
import clsx from 'clsx';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateRate } from '../hooks/useRateData';
|
||||
import { CreateRateType } from '../types/Types';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { PositivePointEnum, NegativePointEnum } from '../enum/Enum';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
|
||||
type Props = object
|
||||
|
||||
type Params = {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
function RatingOrderIndex({ }: Props) {
|
||||
|
||||
const { t } = useTranslation("rating");
|
||||
const params: Params = useParams();
|
||||
const router = useRouter();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const { mutate: createRate, isPending } = useCreateRate();
|
||||
|
||||
const [rating, setRating] = useState<number>(5);
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [positivePoints, setPositivePoints] = useState<string[]>([]);
|
||||
const [negativePoints, setNegativePoints] = useState<string[]>([]);
|
||||
|
||||
const positivePointsArray = [
|
||||
PositivePointEnum.GREAT_TASTE,
|
||||
PositivePointEnum.FAST_DELIVERY,
|
||||
PositivePointEnum.GOOD_QUALITY,
|
||||
PositivePointEnum.GOOD_PORTION_SIZE,
|
||||
PositivePointEnum.FRIENDLY_SERVICE,
|
||||
];
|
||||
|
||||
const negativePointsArray = [
|
||||
NegativePointEnum.SMALL_PORTION,
|
||||
NegativePointEnum.SLOW_DELIVERY,
|
||||
NegativePointEnum.POOR_QUALITY,
|
||||
NegativePointEnum.OVERPRICED,
|
||||
NegativePointEnum.UNFRIENDLY_SERVICE,
|
||||
];
|
||||
|
||||
const getTranslationKey = (point: string, isPositive: boolean): string => {
|
||||
const basePath = isPositive ? 'Tabs.Strengths.Options' : 'Tabs.Weeknesses.Options';
|
||||
return `${basePath}.${point}`;
|
||||
};
|
||||
|
||||
const handleToggle = (pointValue: string, isPositive: boolean) => (checked: boolean) => {
|
||||
const setter = isPositive ? setPositivePoints : setNegativePoints;
|
||||
|
||||
if (checked) {
|
||||
setter(prev => [...prev, pointValue]);
|
||||
} else {
|
||||
setter(prev => prev.filter(p => p !== pointValue));
|
||||
}
|
||||
};
|
||||
|
||||
const badPoitns = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
|
||||
{negativePointsArray.map((point) => (
|
||||
<ToggleButton
|
||||
className={{
|
||||
...ToggleButtonClassNames,
|
||||
wrapperActive: 'border-red-400',
|
||||
contentActive: 'text-red-400'
|
||||
}}
|
||||
key={point}
|
||||
name={`weekness_${point}`}
|
||||
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
|
||||
onToggle={handleToggle(point, false)}
|
||||
>
|
||||
{t(getTranslationKey(point, false))}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const goodPoitns = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
|
||||
{positivePointsArray.map((point) => (
|
||||
<ToggleButton
|
||||
key={point}
|
||||
name={`strength_${point}`}
|
||||
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
|
||||
onToggle={handleToggle(point, true)}
|
||||
>
|
||||
{t(getTranslationKey(point, true))}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleRatingChange = (value: number[]) => {
|
||||
setRating(value[0]);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const submitForm = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (rating === 0) {
|
||||
toast(t("Errors.RatingRequired") || "لطفا امتیاز خود را انتخاب کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const rateData: CreateRateType = {
|
||||
foodId: urlParams.get('foodId') || '',
|
||||
orderId: params.orderId,
|
||||
rating,
|
||||
comment,
|
||||
positivePoints,
|
||||
negativePoints,
|
||||
};
|
||||
|
||||
createRate(rateData, {
|
||||
onSuccess: () => {
|
||||
toast(t("Success") || "نظر شما با موفقیت ثبت شد", "success");
|
||||
router.back();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), "error");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className='flex flex-col h-full pt-6'>
|
||||
<h1 className='font-medium'>{t("Heading")}</h1>
|
||||
|
||||
<form onSubmit={submitForm} className='bg-container flex flex-col h-min justify-between rounded-[30px] pt-10 px-6 pb-7.5 mt-4'>
|
||||
|
||||
<div>
|
||||
<div className='w-full'>
|
||||
<RateSelectionBar
|
||||
name='rating'
|
||||
defaultValue={[rating]}
|
||||
onValueChange={handleRatingChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-10'>
|
||||
<TabContainer
|
||||
defaultIndex={1}
|
||||
changeType={TabContainerRenderType.VISIBILITY}
|
||||
className={{
|
||||
...TabContainerClassNames,
|
||||
wrapper: 'p-2!',
|
||||
scrollView: clsx(
|
||||
'border-none! rounded-xl! gap-0! h-full bg-[#EAECF0]! dark:bg-neutral-900! not-dark:gradient-border! grid! grid-cols-2 px-2! py-2! pb-2! w-full overflow-y-hidden justify-center items-center',
|
||||
),
|
||||
title: 'text-sm2! font-normal mt-1',
|
||||
header: 'rounded-lg h-7! w-full',
|
||||
headerActive: 'bg-white',
|
||||
titleActive: 'text-primary'
|
||||
}}>
|
||||
<TabHeader title={t("Tabs.Weeknesses.Title")} viewRenderer={badPoitns()}></TabHeader>
|
||||
<TabHeader title={t("Tabs.Strengths.Title")} viewRenderer={goodPoitns()}></TabHeader>
|
||||
</TabContainer>
|
||||
<div className="w-full mt-6">
|
||||
<label htmlFor='userOpinion' className='text-sm font-medium'>
|
||||
{t("InputComment.Label")}
|
||||
</label>
|
||||
<br />
|
||||
<textarea
|
||||
name='userOpinion'
|
||||
id='userOpinion'
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={t("InputComment.Placeholder")}
|
||||
className='dark:border-neutral-600 dark:bg-neutral-800 mt-2 w-full h-21 px-4 py-2.5 text-xs text-foreground rounded-xl border border-[#D0D0D0] focus:outline-none focus:ring-2 focus:ring-accent'
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isPending}
|
||||
className='mt-auto rounded-xl font-normal cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
{isPending ? t("ButtonSubmitting") || "در حال ارسال..." : t("ButtonSubmit")}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
className='
|
||||
mt-auto rounded-xl bg-white dark:bg-container cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700
|
||||
border border-foreground dark:border-neutral-600 text-foreground font-normal disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
{t("ButtonCancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default RatingOrderIndex
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/RateService";
|
||||
|
||||
export const useCreateRate = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.createRate,
|
||||
});
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { CreateRateType } from "../types/Types";
|
||||
|
||||
export const createRate = async (params: CreateRateType) => {
|
||||
const { data } = await api.post("/public/reviews", params);
|
||||
return data;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export type CreateRateType = {
|
||||
foodId: string;
|
||||
orderId: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
positivePoints: string[];
|
||||
negativePoints: string[];
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CategoriesResponse,
|
||||
FoodsResponse,
|
||||
NotificationsCountResponse,
|
||||
SlidersResponse,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
|
||||
@@ -29,3 +30,14 @@ export const getNotificationsCount = async (): Promise<
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSliders = async (slug: string): Promise<SlidersResponse> => {
|
||||
try {
|
||||
const response = await api.get<SlidersResponse>(
|
||||
`/public/sliders/restaurant/${slug}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useGetAbout } from '../../../about/hooks/useAboutData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
function TransactionsIndex() {
|
||||
|
||||
@@ -37,7 +38,7 @@ function TransactionsIndex() {
|
||||
|
||||
<section
|
||||
aria-label="درباره باشگاه مشتریان"
|
||||
className='mt-3 bg-container dark:bg-container rounded-normal grid grid-cols-4 items-center border border-transparent dark:border-border/50'
|
||||
className={glassSurfaceCard('mt-3 rounded-normal grid grid-cols-4 items-center')}
|
||||
>
|
||||
<div className='col-span-3 py-5 pe-7 ps-5 relative h-36.5'>
|
||||
<div className='w-full h-full absolute top-0 left-0 overflow-clip text-muted-foreground/30 dark:text-foreground/20'>
|
||||
@@ -78,7 +79,7 @@ function TransactionsIndex() {
|
||||
</section>
|
||||
|
||||
{/* بخش نمایش اعتبار کاربر */}
|
||||
<section className='mt-6 bg-container dark:bg-container rounded-normal p-5 border border-transparent dark:border-border/50'>
|
||||
<section className={glassSurfaceCard('mt-6 rounded-normal p-5')}>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
||||
<h2 className='font-bold text-base dark:text-foreground'>اعتبار شما</h2>
|
||||
@@ -92,7 +93,7 @@ function TransactionsIndex() {
|
||||
</section>
|
||||
|
||||
{/* بخش تبدیل امتیاز به اعتبار */}
|
||||
<section className='mt-6 bg-container dark:bg-container rounded-normal p-5 border border-transparent dark:border-border/50'>
|
||||
<section className={glassSurfaceCard('mt-6 rounded-normal p-5')}>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
||||
<h2 className='font-bold text-base dark:text-foreground'>تبدیل امتیاز به اعتبار</h2>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback } from 'react'
|
||||
import { useGetMyCoupons } from '../hooks/useTransactionData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import type { Coupon } from '../types/Types';
|
||||
import { glassSurfaceCard, glassSurfaceCardFlat } from '@/lib/styles/glassSurface';
|
||||
|
||||
function TransactionsIndex() {
|
||||
const { data: myCoupons, isLoading } = useGetMyCoupons();
|
||||
@@ -32,7 +33,7 @@ function TransactionsIndex() {
|
||||
|
||||
<section
|
||||
aria-label="درباره کدهای تخفیف"
|
||||
className='mt-3 bg-container rounded-normal grid grid-cols-4 items-center'
|
||||
className={glassSurfaceCard('mt-3 rounded-normal grid grid-cols-4 items-center')}
|
||||
>
|
||||
<div className='col-span-3 py-9.5 pe-7 ps-5 relative'>
|
||||
<div className='w-full h-full absolute top-0 left-0 overflow-clip'>
|
||||
@@ -94,7 +95,7 @@ function TransactionsIndex() {
|
||||
{coupons.map((coupon) => (
|
||||
<article
|
||||
key={coupon.id}
|
||||
className='bg-container rounded-normal flex justify-between items-center'
|
||||
className={glassSurfaceCardFlat('rounded-normal flex justify-between items-center')}
|
||||
>
|
||||
<div className='py-4 pe-7 ps-5 border-e-2 w-full border-border border-dashed'>
|
||||
<h3 className='font-bold text-sm2'>مبلغ تخفیف: {formatDiscount(coupon)}</h3>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react'
|
||||
import { useGetTransactions } from './hooks/useTransactionData';
|
||||
import { TransactionsTable } from './components/TransactionsTable';
|
||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
function TransactionsReviewIndex() {
|
||||
const { data: transactionsResponse, isLoading: isLoadingTransactions } = useGetTransactions();
|
||||
@@ -56,7 +57,7 @@ function TransactionsReviewIndex() {
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className='w-full h-full bg-container rounded-3xl mt-0'>
|
||||
<div className={glassSurfaceCard('w-full h-full mt-0 rounded-3xl')}>
|
||||
<TransactionsTable transactions={transactions} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -104,3 +104,17 @@ export interface NotificationsCountData {
|
||||
}
|
||||
|
||||
export type NotificationsCountResponse = BaseResponse<NotificationsCountData>;
|
||||
|
||||
export interface Slider {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
imageUrl: string;
|
||||
title?: string | null;
|
||||
link?: string | null;
|
||||
order?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type SlidersResponse = BaseResponse<Slider[]>;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const AddressDetailsModal = ({
|
||||
e.preventDefault();
|
||||
onChangePosition();
|
||||
}}
|
||||
className='bg-background! w-fit! text-foreground! flex items-center gap-2 text-xs'
|
||||
className='bg-background! pattern-secondary-bg w-fit! text-foreground! flex items-center gap-2 text-xs'
|
||||
disabled={isPending}
|
||||
>
|
||||
<Location color='#6b7280' variant='Bold' className='size-4' />
|
||||
|
||||
@@ -1,90 +1,80 @@
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { useGetProfile } from '../../../hooks/userProfileData';
|
||||
import { useEffect } from 'react';
|
||||
import InputField from "@/components/input/InputField";
|
||||
import { useEffect } from "react";
|
||||
import { useGetProfile } from "../../../hooks/userProfileData";
|
||||
import { NominatimReverseGeocodingResponse } from "../../types/Types";
|
||||
|
||||
interface AddressFormProps {
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
onFormDataChange: (data: Partial<AddressFormProps['formData']>) => void;
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
onFormDataChange: (data: Partial<AddressFormProps["formData"]>) => void;
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
formData,
|
||||
selectedAddress,
|
||||
onFormDataChange,
|
||||
}: AddressFormProps) => {
|
||||
export const AddressForm = ({ formData, selectedAddress, onFormDataChange }: AddressFormProps) => {
|
||||
const { data: profileData } = useGetProfile();
|
||||
|
||||
const { data: profileData } = useGetProfile()
|
||||
useEffect(() => {
|
||||
if (profileData?.data?.phone && !formData.phone) {
|
||||
onFormDataChange({ phone: "0" + profileData.data.phone });
|
||||
}
|
||||
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData?.data?.phone && !formData.phone) {
|
||||
onFormDataChange({ phone: '0' + profileData.data.phone });
|
||||
}
|
||||
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
||||
|
||||
return (
|
||||
<div className='px-4'>
|
||||
<InputField
|
||||
htmlFor={'addressTitle'}
|
||||
labelText={'عنوان آدرس'}
|
||||
placeholder='مثال: منزل، محل کار ...'
|
||||
className='bg-inherit'
|
||||
inputClassName='text-xs!'
|
||||
value={formData.title}
|
||||
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
||||
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'addressDetails'}
|
||||
labelText={'جزئیات آدرس'}
|
||||
placeholder='میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.addressDetails}
|
||||
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'phone'}
|
||||
labelText={'شماره تلفن'}
|
||||
placeholder='09123456789'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.phone}
|
||||
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'postalCode'}
|
||||
labelText={'کد پستی'}
|
||||
placeholder={selectedAddress?.address?.postcode || '1234567890'}
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.postalCode || selectedAddress?.address?.postcode || ''}
|
||||
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<div className='inline-flex justify-between items-center w-full mt-8'>
|
||||
<label
|
||||
htmlFor={'isDefault'}
|
||||
className='text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer'
|
||||
>
|
||||
تنظیم به عنوان آدرس پیشفرض
|
||||
</label>
|
||||
<input
|
||||
name={'isDefault'}
|
||||
id={'isDefault'}
|
||||
className='h-4.5 w-4.5 checked:accent-primary cursor-pointer'
|
||||
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
||||
type='checkbox'
|
||||
checked={formData.isDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="px-4">
|
||||
<InputField
|
||||
htmlFor={"addressTitle"}
|
||||
labelText={"عنوان آدرس"}
|
||||
placeholder="مثال: منزل، محل کار ..."
|
||||
className="bg-inherit"
|
||||
inputClassName="text-xs!"
|
||||
value={formData.title}
|
||||
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={"addressDetails"}
|
||||
labelText={"جزئیات آدرس"}
|
||||
placeholder="میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890"
|
||||
className="bg-inherit mt-8"
|
||||
value={formData.addressDetails}
|
||||
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
||||
inputClassName="text-xs!"
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={"phone"}
|
||||
labelText={"شماره تلفن"}
|
||||
placeholder="09123456789"
|
||||
className="bg-inherit mt-8"
|
||||
value={formData.phone}
|
||||
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
||||
inputClassName="text-xs!"
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={"postalCode"}
|
||||
labelText={"کد پستی"}
|
||||
placeholder={selectedAddress?.address?.postcode || "1234567890"}
|
||||
className="bg-inherit mt-8"
|
||||
value={formData.postalCode || selectedAddress?.address?.postcode || ""}
|
||||
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
||||
inputClassName="text-xs!"
|
||||
/>
|
||||
<div className="inline-flex justify-between items-center w-full mt-8">
|
||||
<label htmlFor={"isDefault"} className="text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer">
|
||||
تنظیم به عنوان آدرس پیشفرض
|
||||
</label>
|
||||
<input
|
||||
name={"isDefault"}
|
||||
id={"isDefault"}
|
||||
className="h-4.5 w-4.5 checked:accent-primary cursor-pointer"
|
||||
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
||||
type="checkbox"
|
||||
checked={formData.isDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,134 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCreateAddress, useUpdateAddress } from '../../hooks/useAddressData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { CreateAddressType, UpdateAddressType, NominatimReverseGeocodingResponse, Address } from '../../types/Types';
|
||||
import { toast } from "@/components/Toast";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCreateAddress, useUpdateAddress } from "../../hooks/useAddressData";
|
||||
import { Address, CreateAddressType, NominatimReverseGeocodingResponse, UpdateAddressType } from "../../types/Types";
|
||||
|
||||
interface UseAddressFormProps {
|
||||
selectedPosition: [number, number] | null;
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
fallbackPosition?: [number, number] | null;
|
||||
initialPhone?: string;
|
||||
editMode?: boolean;
|
||||
addressData?: Address | null;
|
||||
selectedPosition: [number, number] | null;
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
fallbackPosition?: [number, number] | null;
|
||||
initialPhone?: string;
|
||||
editMode?: boolean;
|
||||
addressData?: Address | null;
|
||||
}
|
||||
|
||||
export const useAddressForm = ({
|
||||
selectedPosition,
|
||||
selectedAddress,
|
||||
fallbackPosition = [0, 0],
|
||||
initialPhone = '',
|
||||
editMode = false,
|
||||
addressData = null,
|
||||
}: UseAddressFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
const { mutate: createAddress, isPending: isCreating } = useCreateAddress();
|
||||
const { mutate: updateAddress, isPending: isUpdating } = useUpdateAddress();
|
||||
const isPending = isCreating || isUpdating;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
addressDetails: '',
|
||||
phone: initialPhone,
|
||||
postalCode: '',
|
||||
isDefault: false,
|
||||
});
|
||||
export const useAddressForm = ({ selectedPosition, selectedAddress, fallbackPosition = [0, 0], initialPhone = "", editMode = false, addressData = null }: UseAddressFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect");
|
||||
const { mutate: createAddress, isPending: isCreating } = useCreateAddress();
|
||||
const { mutate: updateAddress, isPending: isUpdating } = useUpdateAddress();
|
||||
const isPending = isCreating || isUpdating;
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && addressData) {
|
||||
setFormData({
|
||||
title: addressData.title || '',
|
||||
addressDetails: addressData.address || '',
|
||||
phone: addressData.phone || initialPhone,
|
||||
postalCode: addressData.postalCode || '',
|
||||
isDefault: addressData.isDefault || false,
|
||||
});
|
||||
}
|
||||
}, [editMode, addressData, initialPhone]);
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
addressDetails: "",
|
||||
phone: initialPhone,
|
||||
postalCode: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const handleFormDataChange = (data: Partial<typeof formData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
useEffect(() => {
|
||||
if (editMode && addressData) {
|
||||
setFormData({
|
||||
title: addressData.title || "",
|
||||
addressDetails: addressData.address || "",
|
||||
phone: addressData.phone || initialPhone,
|
||||
postalCode: addressData.postalCode || "",
|
||||
isDefault: addressData.isDefault || false,
|
||||
});
|
||||
}
|
||||
}, [editMode, addressData, initialPhone]);
|
||||
|
||||
const handleFormDataChange = (data: Partial<typeof formData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const submitAddress = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const positionToUse: [number, number] = selectedPosition || fallbackPosition || [0, 0];
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
toast("لطفا عنوان آدرس را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.addressDetails.trim()) {
|
||||
toast("لطفا جزئیات آدرس را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.phone.trim()) {
|
||||
toast("لطفا شماره تلفن را وارد کنید", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseAddressData = {
|
||||
title: formData.title,
|
||||
address: formData.addressDetails,
|
||||
postalCode: formData.postalCode || selectedAddress?.address?.postcode || addressData?.postalCode || "",
|
||||
latitude: positionToUse[0],
|
||||
longitude: positionToUse[1],
|
||||
phone: formData.phone,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
|
||||
const submitAddress = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (editMode && addressData) {
|
||||
const updateData: UpdateAddressType = {
|
||||
...baseAddressData,
|
||||
id: addressData.id,
|
||||
};
|
||||
|
||||
const positionToUse: [number, number] = selectedPosition || fallbackPosition || [0, 0];
|
||||
updateAddress(updateData, {
|
||||
onSuccess: () => {
|
||||
toast("آدرس با موفقیت بهروزرسانی شد", "success");
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : "خطا در بهروزرسانی آدرس";
|
||||
toast(errorMessage, "error");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const createData: CreateAddressType = baseAddressData;
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
toast('لطفا عنوان آدرس را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
createAddress(createData, {
|
||||
onSuccess: () => {
|
||||
toast("آدرس با موفقیت ثبت شد", "success");
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : "خطا در ثبت آدرس";
|
||||
toast(errorMessage, "error");
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!formData.addressDetails.trim()) {
|
||||
toast('لطفا جزئیات آدرس را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.phone.trim()) {
|
||||
toast('لطفا شماره تلفن را وارد کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseAddressData = {
|
||||
title: formData.title,
|
||||
address: formData.addressDetails,
|
||||
city: selectedAddress?.address?.city || selectedAddress?.address?.district || addressData?.city || '',
|
||||
province: selectedAddress?.address?.province || selectedAddress?.address?.state || addressData?.province || '',
|
||||
postalCode: formData.postalCode || selectedAddress?.address?.postcode || addressData?.postalCode || '',
|
||||
latitude: positionToUse[0],
|
||||
longitude: positionToUse[1],
|
||||
phone: formData.phone,
|
||||
isDefault: formData.isDefault,
|
||||
};
|
||||
|
||||
if (editMode && addressData) {
|
||||
const updateData: UpdateAddressType = {
|
||||
...baseAddressData,
|
||||
id: addressData.id,
|
||||
};
|
||||
|
||||
updateAddress(updateData, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس با موفقیت بهروزرسانی شد', 'success');
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در بهروزرسانی آدرس';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const createData: CreateAddressType = baseAddressData;
|
||||
|
||||
createAddress(createData, {
|
||||
onSuccess: () => {
|
||||
toast('آدرس با موفقیت ثبت شد', 'success');
|
||||
if (redirect) {
|
||||
router.replace(redirect);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'خطا در ثبت آدرس';
|
||||
toast(errorMessage, 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
isPending,
|
||||
handleFormDataChange,
|
||||
submitAddress,
|
||||
};
|
||||
return {
|
||||
formData,
|
||||
isPending,
|
||||
handleFormDataChange,
|
||||
submitAddress,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
|
||||
export const formatAddress = (address: Address): string => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
return address.address;
|
||||
};
|
||||
|
||||
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
||||
@@ -11,8 +11,6 @@ export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocoding
|
||||
|
||||
try {
|
||||
return [
|
||||
selectedAddress.address.state ?? selectedAddress.address.province,
|
||||
selectedAddress.address.city,
|
||||
selectedAddress.address.neighbourhood,
|
||||
!selectedAddress.address.road.startsWith('میدان') &&
|
||||
!selectedAddress.address.road.startsWith('بلوار') &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Address } from './types/Types';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { toast } from '@/components/Toast';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -69,7 +70,7 @@ function UserAddressesPage({ }: Props) {
|
||||
};
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
return address.address;
|
||||
};
|
||||
|
||||
const getEditHref = (addressId: string) => {
|
||||
@@ -107,7 +108,7 @@ function UserAddressesPage({ }: Props) {
|
||||
addresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className={`bg-container rounded-container w-full shadow-xl px-4 py-4 flex flex-col justify-between ${address.isDefault ? 'border border-primary' : ''}`}
|
||||
className={glassSurfaceCard(`w-full px-4 py-4 flex flex-col justify-between ${address.isDefault ? 'border border-primary' : ''}`)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h2 className='text-sm font-medium'>{address.title}</h2>
|
||||
@@ -132,7 +133,7 @@ function UserAddressesPage({ }: Props) {
|
||||
onClick={() => handleSetDefault(address.id)}
|
||||
disabled={isSettingDefault}
|
||||
pending={isSettingDefault}
|
||||
className='bg-background! text-foreground! flex items-center gap-2 text-xs h-8 flex-1'
|
||||
className='bg-background! pattern-secondary-bg text-foreground! flex items-center gap-2 text-xs h-8 flex-1'
|
||||
>
|
||||
<TickCircle variant='Bold' className='fill-foreground stroke-background size-4.5 mb-0.5' />
|
||||
تنظیم به عنوان فعال
|
||||
@@ -140,14 +141,14 @@ function UserAddressesPage({ }: Props) {
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Link href={getEditHref(address.id)}>
|
||||
<Button className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'>
|
||||
<Button className='bg-background! pattern-secondary-bg text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'>
|
||||
<Edit2 className='stroke-foreground size-5 mb-0.5' />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => handleDeleteClick(address.id)}
|
||||
disabled={isDeleting}
|
||||
className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'
|
||||
className='bg-background! pattern-secondary-bg text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'
|
||||
>
|
||||
<Trash className='stroke-foreground size-5 mb-0.5' />
|
||||
</Button>
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface Address {
|
||||
user: string;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -25,8 +23,6 @@ export type AddressResponse = BaseResponse<Address>;
|
||||
export type CreateAddressType = {
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import Calendar from '@/components/ui/calendar';
|
||||
import { getDateLib } from "react-day-picker/persian";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useGetProfile, useSingleUpload, useUpdateProfile } from '../hooks/userProfileData';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { UpdateProfileType } from '../types/Types';
|
||||
@@ -197,7 +198,7 @@ function ProfileIndex({ }: Props) {
|
||||
<h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="mt-8 flex-1 bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
<form onSubmit={formik.handleSubmit} className={glassSurfaceCard('mt-8 flex-1 w-full py-6 px-4 flex flex-col justify-between')}>
|
||||
<div className="bg-inherit">
|
||||
<div className="justify-self-center w-fit relative text-center pb-10">
|
||||
<input
|
||||
|
||||
@@ -9,6 +9,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import { useGetProfile } from './hooks/userProfileData';
|
||||
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||
|
||||
type Props = object
|
||||
|
||||
@@ -43,7 +44,7 @@ function ProfileIndex({ }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
<div className={glassSurfaceCard('mt-8 flex-1 h-full w-full py-6 px-4 flex flex-col justify-between')}>
|
||||
|
||||
<div className="">
|
||||
<div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border">
|
||||
|
||||
@@ -1,259 +1,198 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import PasswordField from '@/components/input/PasswordField';
|
||||
import TabContainer from '@/components/tab/TabContainer'
|
||||
import { TabHeader } from '@/components/tab/TabHeader'
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import Accordion from '@/components/utils/Accordion';
|
||||
import Seperator from '@/components/utils/Seperator';
|
||||
import { SmartIcon } from '@/components/utils/SmartIcon';
|
||||
import { getI18nObject } from '@/lib/i18n';
|
||||
import { SettingsData } from '@/types/i18n/settings';
|
||||
import { Key, KeySquare, Notification1, ArrowLeft, TickCircle } from 'iconsax-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from "@/components/button/PrimaryButton";
|
||||
import PasswordField from "@/components/input/PasswordField";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import Accordion from "@/components/utils/Accordion";
|
||||
import Seperator from "@/components/utils/Seperator";
|
||||
import { SmartIcon } from "@/components/utils/SmartIcon";
|
||||
import { getI18nObject } from "@/lib/i18n";
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
import { SettingsData } from "@/types/i18n/settings";
|
||||
import { ArrowLeft, Key, TickCircle } from "iconsax-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function UserSettingsIndex() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('settings');
|
||||
const data: SettingsData = getI18nObject(t, "data");
|
||||
const [statisticalParticipation, setStatisticalParticipation] = useState(true);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("settings");
|
||||
const data: SettingsData = getI18nObject(t, "data");
|
||||
const [statisticalParticipation, setStatisticalParticipation] = useState(true);
|
||||
|
||||
const onSwitched = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
console.log('Save changed setting?', target.id);
|
||||
}
|
||||
const onSwitched = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
console.log("Save changed setting?", target.id);
|
||||
};
|
||||
|
||||
const handleStatisticalToggle = (checked: boolean) => {
|
||||
setStatisticalParticipation(checked);
|
||||
console.log('Statistical participation:', checked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const notifsTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="about-title" className='py-4 flex-1 h-full'>
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className='text-sm leading-5'>{data.TabNotifications.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<div className='p-2 pt-0'>
|
||||
{data.TabNotifications.Accordions.map((accordion, i) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<Accordion
|
||||
title={accordion.Heading}
|
||||
group={'accordions'}
|
||||
icon={accordion.Icon}
|
||||
>
|
||||
<div className='grid gap-4 mb-3'>
|
||||
{accordion.Items.map((item, j) => {
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={item.HtmlName}
|
||||
className="flex w-full h-11 items-center justify-between gap-2 px-3 py-4 relative bg-container/29 rounded-xl border border-solid border-neutral-200 dark:border-neutral-600 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5 text-sm2">
|
||||
<SmartIcon icon={item.Icon} />
|
||||
{item.Label}
|
||||
</span>
|
||||
<Switch
|
||||
onClick={onSwitched}
|
||||
id={item.HtmlName}
|
||||
name={item.HtmlName}
|
||||
className="w-12 h-6 scale-[0.9]"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Accordion>
|
||||
<Seperator className='last:hidden' />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const passwordTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className='py-4'>
|
||||
<section
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className='text-sm leading-5'>{data.TabPassword.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className='grid gap-6 p-2'
|
||||
>
|
||||
{data.TabPassword.Inputs.map((input, i) => {
|
||||
return (
|
||||
<PasswordField
|
||||
labelText={input.Label}
|
||||
className='bg-container'
|
||||
key={i}
|
||||
htmlFor={input.HtmlName ?? ''}
|
||||
placeholder={input.Placeholder}
|
||||
autoComplete={input.AutoComplete}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
{data.TabPassword.Rules.map((rule, i) => {
|
||||
return (
|
||||
<section
|
||||
key={i}
|
||||
className='bg-background rounded-normal p-4 text-xs flex flex-col gap-2 font-light'
|
||||
>
|
||||
<h6 className='text-sm mb-2'>{rule.Heading}</h6>
|
||||
{rule.Items.map((item, i) => {
|
||||
return (
|
||||
<li key={i}>{item}</li>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button className='mt-4'>
|
||||
<div className='flex items-center justify-center gap-1'>
|
||||
<TickCircle className='stroke-white' size={20} />
|
||||
<span className='mt-0.5'>
|
||||
{data.TabPassword.ButtonSubmit}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const authenticatorTab = () => {
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className='py-4'>
|
||||
<div
|
||||
className="bg-container rounded-container shadow-container p-4">
|
||||
|
||||
<div className="pb-4">
|
||||
<h2 className='text-sm leading-5'>{data.TabAuthenticator.Heading}</h2>
|
||||
<p className='text-xs mt-3 text-disabled-text font-light'>{data.TabAuthenticator.Description}</p>
|
||||
</div>
|
||||
|
||||
<Seperator />
|
||||
|
||||
<div className='p-2'>
|
||||
<div className='mt-14 bg-blue-100 p-4 place-self-center rounded-full'>
|
||||
<Key size={32} className='stroke-blue-500' />
|
||||
</div>
|
||||
<p className='text-sm2 mt-4 font-light place-self-center'>
|
||||
{data.TabAuthenticator.Tip}
|
||||
</p>
|
||||
|
||||
<div className='place-self-center mt-2'>
|
||||
<Button className='mt-4 px-14 py-2'>
|
||||
<div className='flex items-center justify-center gap-1'>
|
||||
<TickCircle className='stroke-white' size={20} />
|
||||
<span className='mt-0.5'>
|
||||
{data.TabAuthenticator.ButtonSubmit}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.TabAuthenticator.Guides.map((guide, i) => {
|
||||
return (
|
||||
<section
|
||||
key={i}
|
||||
className='bg-background rounded-normal mt-14 p-4 text-xs flex flex-col gap-2 font-light'
|
||||
>
|
||||
<h6 className='text-sm'>{guide.Heading}</h6>
|
||||
<p className='text-disabled-text'>{guide.Description}</p>
|
||||
{guide.Items.map((item, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className='ml-2 mt-2 bg-blue-100 inline-block size-6 text-center pt-[5px] pr-[1px] text-blue-500 rounded-full'>
|
||||
{item.Mark}
|
||||
</div>
|
||||
<span>
|
||||
{item.Label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{guide.Notes.map((note, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='mt-4 p-3 text-yellow-800 rounded-lg bg-yellow-50 border border-solid border-yellow-200'
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const handleStatisticalToggle = (checked: boolean) => {
|
||||
setStatisticalParticipation(checked);
|
||||
console.log("Statistical participation:", checked);
|
||||
};
|
||||
|
||||
const notifsTab = () => {
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col gap-6'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{data.Heading}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
<section aria-labelledby="about-title" className="py-4 flex-1 h-full">
|
||||
<section className={glassSurfaceCard("p-4")}>
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className="text-sm leading-5">{data.TabNotifications.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-2 pt-0">
|
||||
{data.TabNotifications.Accordions.map((accordion, i) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<Accordion title={accordion.Heading} group={"accordions"} icon={accordion.Icon}>
|
||||
<div className="grid gap-4 mb-3">
|
||||
{accordion.Items.map((item, j) => {
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={item.HtmlName}
|
||||
className="flex w-full h-11 items-center justify-between gap-2 px-3 py-4 relative bg-container/29 rounded-xl border border-solid border-neutral-200 dark:border-neutral-600 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2.5 text-sm2">
|
||||
<SmartIcon icon={item.Icon} />
|
||||
{item.Label}
|
||||
</span>
|
||||
<Switch onClick={onSwitched} id={item.HtmlName} name={item.HtmlName} className="w-12 h-6 scale-[0.9]" />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Accordion>
|
||||
<Seperator className="last:hidden" />
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const passwordTab = () => {
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className="py-4">
|
||||
<section className={glassSurfaceCard("p-4")}>
|
||||
<div className="flex justify-between items-center pb-[25px]">
|
||||
<h2 className="text-sm leading-5">{data.TabPassword.Heading}</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()} className="grid gap-6 p-2">
|
||||
{data.TabPassword.Inputs.map((input, i) => {
|
||||
return <PasswordField labelText={input.Label} className="bg-container" key={i} htmlFor={input.HtmlName ?? ""} placeholder={input.Placeholder} autoComplete={input.AutoComplete} />;
|
||||
})}
|
||||
|
||||
{data.TabPassword.Rules.map((rule, i) => {
|
||||
return (
|
||||
<section key={i} className="bg-background rounded-normal p-4 text-xs flex flex-col gap-2 font-light">
|
||||
<h6 className="text-sm mb-2">{rule.Heading}</h6>
|
||||
{rule.Items.map((item, i) => {
|
||||
return <li key={i}>{item}</li>;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button className="mt-4">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TickCircle className="stroke-white" size={20} />
|
||||
<span className="mt-0.5">{data.TabPassword.ButtonSubmit}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const authenticatorTab = () => {
|
||||
return (
|
||||
<section aria-labelledby="reviews-title" className="py-4">
|
||||
<div className={glassSurfaceCard("p-4")}>
|
||||
<div className="pb-4">
|
||||
<h2 className="text-sm leading-5">{data.TabAuthenticator.Heading}</h2>
|
||||
<p className="text-xs mt-3 text-disabled-text font-light">{data.TabAuthenticator.Description}</p>
|
||||
</div>
|
||||
|
||||
<Seperator />
|
||||
|
||||
<div className="p-2">
|
||||
<div className="mt-14 bg-blue-100 p-4 place-self-center rounded-full">
|
||||
<Key size={32} className="stroke-blue-500" />
|
||||
</div>
|
||||
<p className="text-sm2 mt-4 font-light place-self-center">{data.TabAuthenticator.Tip}</p>
|
||||
|
||||
<div className="place-self-center mt-2">
|
||||
<Button className="mt-4 px-14 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TickCircle className="stroke-white" size={20} />
|
||||
<span className="mt-0.5">{data.TabAuthenticator.ButtonSubmit}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className='bg-container rounded-container shadow-container p-4'>
|
||||
<div className='flex justify-between items-center pb-4'>
|
||||
<h2 className='text-sm leading-5'>{data.StatisticalParticipation.Heading}</h2>
|
||||
<Switch
|
||||
checked={statisticalParticipation}
|
||||
onCheckedChange={handleStatisticalToggle}
|
||||
id={data.StatisticalParticipation.HtmlName}
|
||||
name={data.StatisticalParticipation.HtmlName}
|
||||
className="w-12 h-6 scale-[0.9]"
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-disabled-text font-light'>
|
||||
{data.StatisticalParticipation.Description}
|
||||
</p>
|
||||
</section>
|
||||
{data.TabAuthenticator.Guides.map((guide, i) => {
|
||||
return (
|
||||
<section key={i} className="bg-background rounded-normal mt-14 p-4 text-xs flex flex-col gap-2 font-light">
|
||||
<h6 className="text-sm">{guide.Heading}</h6>
|
||||
<p className="text-disabled-text">{guide.Description}</p>
|
||||
{guide.Items.map((item, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="ml-2 mt-2 bg-blue-100 inline-block size-6 text-center pt-[5px] pr-px text-blue-500 rounded-full">{item.Mark}</div>
|
||||
<span>{item.Label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{guide.Notes.map((note, i) => {
|
||||
return (
|
||||
<div key={i} className="mt-4 p-3 text-yellow-800 rounded-lg bg-yellow-50 border border-solid border-yellow-200">
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full noscrollbar flex flex-col gap-6">
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
<span></span>
|
||||
<h1 className="text-sm2 place-self-center font-medium">{data.Heading}</h1>
|
||||
<ArrowLeft
|
||||
className="cursor-pointer place-self-end"
|
||||
size="24"
|
||||
color="currentColor"
|
||||
onClick={() => {
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className={glassSurfaceCard("p-4")}>
|
||||
<div className="flex justify-between items-center pb-4">
|
||||
<h2 className="text-sm leading-5">{data.StatisticalParticipation.Heading}</h2>
|
||||
<Switch
|
||||
checked={statisticalParticipation}
|
||||
onCheckedChange={handleStatisticalToggle}
|
||||
id={data.StatisticalParticipation.HtmlName}
|
||||
name={data.StatisticalParticipation.HtmlName}
|
||||
className="w-12 h-6 scale-[0.9]"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-disabled-text font-light">{data.StatisticalParticipation.Description}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSettingsIndex
|
||||
export default UserSettingsIndex;
|
||||
|
||||
@@ -1,111 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { ef, ue } from '@/lib/helpers/utfNumbers';
|
||||
import { ArrowLeft, ArrowRight, WalletMinus } from 'iconsax-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react'
|
||||
import Button from "@/components/button/PrimaryButton";
|
||||
import InputField from "@/components/input/InputField";
|
||||
import { ef, ue } from "@/lib/helpers/utfNumbers";
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
import { ArrowLeft, ArrowRight, WalletMinus } from "iconsax-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
|
||||
type Props = object
|
||||
type Props = object;
|
||||
|
||||
function UserWalletIndex({ }: Props) {
|
||||
const router = useRouter();
|
||||
const [chargeValue, setChargeValue] = useState<string>('0');
|
||||
const chargeAction = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget)
|
||||
const value = Number(ue(String(data.get('chargeValue') ?? 0))) + 0;
|
||||
console.log("Charge for: ", value);
|
||||
}
|
||||
function UserWalletIndex({}: Props) {
|
||||
const router = useRouter();
|
||||
const [chargeValue, setChargeValue] = useState<string>("0");
|
||||
const chargeAction = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const value = Number(ue(String(data.get("chargeValue") ?? 0))) + 0;
|
||||
console.log("Charge for: ", value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>پروفایل کاربری</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
return (
|
||||
<div className="overflow-y-auto h-full noscrollbar flex flex-col">
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
<span></span>
|
||||
<h1 className="text-sm2 place-self-center font-medium">پروفایل کاربری</h1>
|
||||
<ArrowLeft
|
||||
className="cursor-pointer place-self-end"
|
||||
size="24"
|
||||
color="currentColor"
|
||||
onClick={() => {
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={chargeAction} className={glassSurfaceCard("mt-8 flex-1 h-full w-full py-6 px-4 flex flex-col justify-between")}>
|
||||
<div className="">
|
||||
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
|
||||
<div className="block">
|
||||
<div className="font-medium text-sm flex items-center gap-2">
|
||||
<WalletMinus className="size-5 stroke-foreground mb-0.5" />
|
||||
موجودی کیف پول
|
||||
</div>
|
||||
<div className="text-sm text-primary mt-3 inline-flex dark:text-neutral-200 items-center gap-2">{ef("250.000 تومان")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={chargeAction}
|
||||
className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between">
|
||||
|
||||
<div className="">
|
||||
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
|
||||
<div className='block'>
|
||||
<div className='font-medium text-sm flex items-center gap-2'>
|
||||
<WalletMinus className='size-5 stroke-foreground mb-0.5' />
|
||||
موجودی کیف پول
|
||||
</div>
|
||||
<div className='text-sm text-primary mt-3 inline-flex dark:text-neutral-200 items-center gap-2'>
|
||||
{ef('250.000 تومان')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 mt-6">
|
||||
<div className='inline-flex items-center mb-3 gap-2.5 text-sm2'>
|
||||
برای شارژ کیف پول خود میتوانید بر روی یکی از مبالغ زیر کلیک کنید و یا مبلغ دلخواه خود را وارد کنید
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('50000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('50,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('150000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('150,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => setChargeValue('300000')}
|
||||
className='!bg-disabled text-sm2 !text-foreground flex justify-between items-center'
|
||||
>
|
||||
<ArrowRight className='stroke-foreground size-5' />
|
||||
<span className='mt-0.5'>
|
||||
{ef('300,000 تومان')}
|
||||
</span>
|
||||
</Button>
|
||||
<InputField
|
||||
className='bg-container mt-7'
|
||||
labelText='مبلغ دلخواه (تومان)'
|
||||
dir='ltr'
|
||||
inputMode='decimal'
|
||||
htmlFor='chargeValue'
|
||||
placeholder=''
|
||||
value={ef(chargeValue)}
|
||||
onChange={(e) => setChargeValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full text-center mt-6'>
|
||||
<Button
|
||||
type='submit'
|
||||
>
|
||||
شارژ کیف پول
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="grid gap-3 mt-6">
|
||||
<div className="inline-flex items-center mb-3 gap-2.5 text-sm2">برای شارژ کیف پول خود میتوانید بر روی یکی از مبالغ زیر کلیک کنید و یا مبلغ دلخواه خود را وارد کنید</div>
|
||||
<Button type="button" onClick={() => setChargeValue("50000")} className="bg-disabled! text-sm2 text-foreground! flex justify-between items-center">
|
||||
<ArrowRight className="stroke-foreground size-5" />
|
||||
<span className="mt-0.5">{ef("50,000 تومان")}</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setChargeValue("150000")} className="bg-disabled! text-sm2 text-foreground! flex justify-between items-center">
|
||||
<ArrowRight className="stroke-foreground size-5" />
|
||||
<span className="mt-0.5">{ef("150,000 تومان")}</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setChargeValue("300000")} className="bg-disabled! text-sm2 text-foreground! flex justify-between items-center">
|
||||
<ArrowRight className="stroke-foreground size-5" />
|
||||
<span className="mt-0.5">{ef("300,000 تومان")}</span>
|
||||
</Button>
|
||||
<InputField
|
||||
className="bg-container mt-7"
|
||||
labelText="مبلغ دلخواه (تومان)"
|
||||
dir="ltr"
|
||||
inputMode="decimal"
|
||||
htmlFor="chargeValue"
|
||||
placeholder=""
|
||||
value={ef(chargeValue)}
|
||||
onChange={(e) => setChargeValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
<div className="w-full text-center mt-6">
|
||||
<Button type="submit">شارژ کیف پول</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserWalletIndex
|
||||
export default UserWalletIndex;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import ActiveChecker from "@/components/ActiveChecker";
|
||||
import AppBackground from "@/components/background/AppBackground";
|
||||
import CartChecker from "@/components/CartChecker";
|
||||
import RestaurantHeadManager from "@/components/RestaurantHeadManager";
|
||||
import RestaurantNotFoundGuard from "@/components/RestaurantNotFoundGuard";
|
||||
import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper";
|
||||
import { PatternBackgroundProvider } from "@/components/background/PatternBackgroundProvider";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -15,7 +17,12 @@ export default function RestaurantLayoutClient({ children }: Props) {
|
||||
<>
|
||||
<RestaurantHeadManager />
|
||||
<RestaurantNotFoundGuard>
|
||||
<PreferenceWrapper>{children}</PreferenceWrapper>
|
||||
<PatternBackgroundProvider>
|
||||
<PreferenceWrapper>
|
||||
<AppBackground />
|
||||
{children}
|
||||
</PreferenceWrapper>
|
||||
</PatternBackgroundProvider>
|
||||
</RestaurantNotFoundGuard>
|
||||
<CartChecker />
|
||||
<ActiveChecker />
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
const res = await fetchWithTimeout(url, {
|
||||
timeoutMs: 5000,
|
||||
timeoutMs: 15_000,
|
||||
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
fetchAboutResponse,
|
||||
fetchCategoriesResponse,
|
||||
fetchFoodsResponse,
|
||||
fetchSlidersResponse,
|
||||
} from "./serverMenuFetch";
|
||||
|
||||
export async function prefetchMenuPageData(
|
||||
@@ -22,5 +23,9 @@ export async function prefetchMenuPageData(
|
||||
queryKey: ["categories", name],
|
||||
queryFn: () => fetchCategoriesResponse(name),
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["sliders", name],
|
||||
queryFn: () => fetchSlidersResponse(name),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import axios from "axios";
|
||||
import { API_BASE_URL } from "@/config/const";
|
||||
import type { AboutResponse } from "../(Main)/about/types/Types";
|
||||
import type { CategoriesResponse, FoodsResponse } from "../(Main)/types/Types";
|
||||
import type {
|
||||
CategoriesResponse,
|
||||
FoodsResponse,
|
||||
SlidersResponse,
|
||||
} from "../(Main)/types/Types";
|
||||
import { getRestaurant, RESTAURANT_FETCH_TIMEOUT_MS } from "./getRestaurant";
|
||||
|
||||
function createServerAxios(slug: string) {
|
||||
@@ -37,3 +41,17 @@ export async function fetchCategoriesResponse(
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchSlidersResponse(
|
||||
slug: string,
|
||||
): Promise<SlidersResponse> {
|
||||
try {
|
||||
const client = createServerAxios(slug);
|
||||
const { data } = await client.get<SlidersResponse>(
|
||||
`/public/sliders/restaurant/${slug}`,
|
||||
);
|
||||
return data;
|
||||
} catch {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { PublicRestaurant } from "@/app/types/Types";
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
import { ArrowLeft2, Location } from "iconsax-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const FALLBACK_LOGO = "/assets/images/avatar.svg";
|
||||
|
||||
function isValidLogoUrl(url: string | null | undefined): url is string {
|
||||
if (!url?.trim()) return false;
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.endsWith(".pdf")) return false;
|
||||
return lower.startsWith("http") || lower.startsWith("/");
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "?";
|
||||
return trimmed.charAt(0);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
restaurant: PublicRestaurant;
|
||||
};
|
||||
|
||||
export default function RestaurantCard({ restaurant }: Props) {
|
||||
const validLogo = isValidLogoUrl(restaurant.logo);
|
||||
const [logoSrc, setLogoSrc] = useState<string>(validLogo && restaurant.logo ? restaurant.logo : FALLBACK_LOGO);
|
||||
const showInitials = !validLogo || logoSrc === FALLBACK_LOGO;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${restaurant.slug}`}
|
||||
className={glassSurfaceCard(
|
||||
"group flex items-center gap-4 p-4 transition-all duration-200 ease-out",
|
||||
"hover:shadow-md active:scale-[0.98] outline-none [-webkit-tap-highlight-color:transparent]",
|
||||
)}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{showInitials ? (
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-(--danak-orange-light) text-xl font-medium text-(--danak-orange)">{getInitials(restaurant.name)}</div>
|
||||
) : (
|
||||
<Image src={logoSrc} alt={restaurant.name} width={64} height={64} className="size-16 rounded-2xl bg-[#F2F2F9] object-cover ring-1 ring-black/5" onError={() => setLogoSrc(FALLBACK_LOGO)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="truncate text-sm font-medium text-foreground">{restaurant.name}</h2>
|
||||
</div>
|
||||
|
||||
{restaurant.address && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5 text-[#8C90A3]">
|
||||
<Location stroke="currentColor" size={14} className="mt-0.5 shrink-0 text-(--danak-orange)" variant="Bold" />
|
||||
<p className="line-clamp-2 text-xs leading-5">{restaurant.address?.trim()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restaurant.establishedYear != null && (
|
||||
<p className="mt-1.5 text-[11px] text-[#8C90A3]">
|
||||
سال تاسیس <span className="font-medium text-(--danak-orange)">{restaurant.establishedYear}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ArrowLeft2 size={18} className="shrink-0 text-[#C4C7D4] transition-transform duration-200 group-hover:-translate-x-0.5 group-hover:text-(--danak-orange)" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className={glassSurfaceCard("flex animate-pulse items-center gap-4 p-4")}>
|
||||
<div className="size-16 shrink-0 rounded-2xl bg-(--danak-orange-muted)" />
|
||||
<div className="flex-1 space-y-2.5">
|
||||
<div className="h-4 w-2/3 rounded-lg bg-black/5" />
|
||||
<div className="h-3 w-full rounded-lg bg-black/5" />
|
||||
<div className="h-3 w-4/5 rounded-lg bg-black/5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RestaurantsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 px-4 pb-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "../../public/assets/css/fonts.css";
|
||||
@import "tw-animate-css";
|
||||
@import "../styles/glass.css";
|
||||
|
||||
/* @custom-variant dark (&:is(.dark *)); */
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
@@ -395,4 +396,38 @@ html[data-theme="dark"] {
|
||||
background-color: var(--primary);
|
||||
-webkit-mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
||||
mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
||||
}
|
||||
|
||||
html[data-pattern-bg="true"] #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html[data-pattern-bg="true"] [data-slot="dialog-content"] {
|
||||
background-color: var(--container) !important;
|
||||
}
|
||||
|
||||
html[data-pattern-bg="true"] [data-slot="splash-screen"] {
|
||||
background-color: var(--container) !important;
|
||||
}
|
||||
|
||||
html[data-pattern-bg="true"] .pattern-secondary-bg {
|
||||
background-color: var(--primary-light) !important;
|
||||
}
|
||||
|
||||
html[data-image-bg="true"] #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html[data-image-bg="true"] [data-slot="dialog-content"] {
|
||||
background-color: var(--container) !important;
|
||||
}
|
||||
|
||||
html[data-image-bg="true"] [data-slot="splash-screen"] {
|
||||
background-color: var(--container) !important;
|
||||
}
|
||||
|
||||
html[data-image-bg="true"] .pattern-secondary-bg {
|
||||
background-color: var(--primary-light) !important;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.danak-home {
|
||||
--danak-orange: #ff5e2b;
|
||||
--danak-orange-dark: #e84e1c;
|
||||
--danak-orange-light: rgb(255 94 43 / 12%);
|
||||
--danak-orange-muted: rgb(255 94 43 / 6%);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/Service";
|
||||
|
||||
export const useGetAllRestaurants = () => {
|
||||
return useQuery({
|
||||
queryKey: ["restaurants"],
|
||||
queryFn: api.getAllRestaurants,
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import initTranslations from '@/lib/i18n';
|
||||
import TranslationsProvider from "@/components/utils/TranslationsProdiver";
|
||||
import ToastContainer from "@/components/Toast";
|
||||
import { ThemeColorSetter } from "@/components/ThemeColorSetter";
|
||||
import { ThemeBootScript } from "@/components/ThemeBootScript";
|
||||
import PwaServiceWorker from "@/components/PwaServiceWorker";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -48,6 +49,7 @@ export default async function RootLayout({
|
||||
|
||||
className={`antialiased bg-background h-svh overflow-hidden`}
|
||||
>
|
||||
<ThemeBootScript />
|
||||
<ReactQueryProvider>
|
||||
<TranslationsProvider
|
||||
namespaces={i18nNamespaces}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
|
||||
import React from 'react'
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<LoadingOverlay />
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
+73
-12
@@ -1,14 +1,75 @@
|
||||
import Link from 'next/link'
|
||||
"use client";
|
||||
|
||||
export default function Home () {
|
||||
return (
|
||||
<div>
|
||||
<Link href={'/auth'}>Auth</Link>
|
||||
<br />
|
||||
<Link href={'/zhivan'}>Menu</Link>
|
||||
<br />
|
||||
<Link href={'/'}>Index</Link>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
import RestaurantCard from "@/app/components/RestaurantCard";
|
||||
import RestaurantsSkeleton from "@/app/components/RestaurantsSkeleton";
|
||||
import { useGetAllRestaurants } from "@/app/hooks/useAppData";
|
||||
import { PublicRestaurant } from "@/app/types/Types";
|
||||
import SearchBox from "@/components/input/SearchBox";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import "./home.css";
|
||||
|
||||
function filterRestaurants(restaurants: PublicRestaurant[], query: string): PublicRestaurant[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return restaurants;
|
||||
|
||||
return restaurants.filter((r) => r.name.toLowerCase().includes(normalized) || r.slug.toLowerCase().includes(normalized) || r.address?.toLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { data, isLoading, error } = useGetAllRestaurants();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const restaurants = data?.data ?? [];
|
||||
const filtered = useMemo(() => filterRestaurants(restaurants, search), [restaurants, search]);
|
||||
|
||||
return (
|
||||
<div className="danak-home flex h-full flex-col overflow-hidden bg-[#ff5e2b]/20">
|
||||
<header className="shrink-0 px-4 pt-6 pb-3">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Image src="/icons/logo512.png" alt="BY DANAK" width={52} height={52} className="size-[52px] rounded-[14px] object-cover" priority />
|
||||
<h1 className="mt-3 text-base font-semibold text-foreground">دیمنو پلاس</h1>
|
||||
<p className="mt-0.5 text-[11px] tracking-wide text-(--danak-orange)">BY DANAK</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<SearchBox value={search} onChange={(e) => setSearch(e.target.value)} placeholder="جستجوی رستوران یا کافه..." />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto noscrollbar">
|
||||
{!isLoading && !error && (
|
||||
<p className="px-4 pb-3 text-xs text-[#8C90A3]">
|
||||
{filtered.length.toLocaleString("fa-IR")} مورد
|
||||
{search.trim() ? " یافت شد" : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && <RestaurantsSkeleton />}
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-red-700">خطا در دریافت لیست رستورانها</p>
|
||||
<p className="mt-1 text-xs text-red-500">لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<Image src="/icons/logo512.png" alt="" width={48} height={48} className="mb-4 size-12 rounded-xl object-cover opacity-40" />
|
||||
<p className="text-sm font-medium text-foreground">{search.trim() ? "نتیجهای یافت نشد" : "رستورانی یافت نشد"}</p>
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">{search.trim() ? "عبارت جستجو را تغییر دهید" : "در حال حاضر رستورانی در لیست وجود ندارد"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filtered.length > 0 && (
|
||||
<div className="space-y-3 px-4 pb-8">
|
||||
{filtered.map((restaurant) => (
|
||||
<RestaurantCard key={restaurant.slug} restaurant={restaurant} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { RestaurantsListResponse } from "@/app/types/Types";
|
||||
import { api } from "@/config/axios";
|
||||
|
||||
export const getAllRestaurants = async (): Promise<RestaurantsListResponse> => {
|
||||
const { data } = await api.get("/public/restaurants");
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface PublicRestaurant {
|
||||
slug: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
logo: string | null;
|
||||
establishedYear: number | null;
|
||||
}
|
||||
|
||||
export type RestaurantsListResponse = BaseResponse<PublicRestaurant[]>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getThemeBootScript } from "@/lib/helpers/themeBootScript";
|
||||
|
||||
export function ThemeBootScript() {
|
||||
return (
|
||||
<script
|
||||
id="theme-boot"
|
||||
dangerouslySetInnerHTML={{ __html: getThemeBootScript() }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'
|
||||
import { getCachedMenuColor, getRestaurantSlugFromPath, getThemeCache, applyCachedThemeToDom } from '@/lib/helpers/themeCache'
|
||||
|
||||
export function ThemeColorSetter() {
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const savedColor = localStorage.getItem('theme-primary-color')
|
||||
const slug = getRestaurantSlugFromPath()
|
||||
const cachedTheme = slug ? getThemeCache(slug) : null
|
||||
if (cachedTheme) {
|
||||
applyCachedThemeToDom(cachedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
const savedColor = getCachedMenuColor(slug)
|
||||
if (savedColor) {
|
||||
const oklchColor = hexToOklch(savedColor)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { usePatternBackground } from "@/components/background/PatternBackgroundProvider";
|
||||
import usePreference from "@/hooks/helpers/usePreference";
|
||||
import { THEME_BG_ELEMENT_ID } from "@/lib/helpers/themeCache";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
const THEME_BG_IMG_ID = "cached-theme-bg-img";
|
||||
const THEME_BG_OVERLAY_ID = "cached-theme-bg-overlay";
|
||||
|
||||
export default function AppBackground() {
|
||||
const { state: nightMode } = usePreference("night-mode", false);
|
||||
const { isPattern, backgroundStyle, isCustomImage, customImageSettings } =
|
||||
usePatternBackground();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (nightMode || (!isPattern && !isCustomImage)) {
|
||||
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
let element = document.getElementById(THEME_BG_ELEMENT_ID);
|
||||
if (!element) {
|
||||
element = document.createElement("div");
|
||||
element.id = THEME_BG_ELEMENT_ID;
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
element.style.position = "fixed";
|
||||
element.style.inset = "0";
|
||||
element.style.pointerEvents = "none";
|
||||
element.style.zIndex = "0";
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
|
||||
if (isPattern) {
|
||||
element.style.overflow = "";
|
||||
document.getElementById(THEME_BG_IMG_ID)?.remove();
|
||||
document.getElementById(THEME_BG_OVERLAY_ID)?.remove();
|
||||
Object.assign(element.style, backgroundStyle as Record<string, string>);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustomImage && customImageSettings) {
|
||||
const { bgUrl, bgBlur, bgOpacity, bgOverlay } = customImageSettings;
|
||||
|
||||
element.style.backgroundColor = "";
|
||||
element.style.backgroundImage = "";
|
||||
element.style.overflow = "hidden";
|
||||
|
||||
let imgLayer = document.getElementById(THEME_BG_IMG_ID);
|
||||
if (!imgLayer) {
|
||||
imgLayer = document.createElement("div");
|
||||
imgLayer.id = THEME_BG_IMG_ID;
|
||||
imgLayer.style.position = "absolute";
|
||||
imgLayer.style.inset = "-40px";
|
||||
imgLayer.style.backgroundSize = "cover";
|
||||
imgLayer.style.backgroundPosition = "center";
|
||||
imgLayer.style.backgroundRepeat = "no-repeat";
|
||||
element.appendChild(imgLayer);
|
||||
}
|
||||
|
||||
imgLayer.style.backgroundImage = `url("${bgUrl}")`;
|
||||
imgLayer.style.filter = bgBlur ? `blur(${bgBlur}px)` : "";
|
||||
imgLayer.style.opacity =
|
||||
bgOpacity != null ? String(bgOpacity / 100) : "1";
|
||||
|
||||
let overlayLayer = document.getElementById(THEME_BG_OVERLAY_ID);
|
||||
if (!overlayLayer) {
|
||||
overlayLayer = document.createElement("div");
|
||||
overlayLayer.id = THEME_BG_OVERLAY_ID;
|
||||
overlayLayer.style.position = "absolute";
|
||||
overlayLayer.style.inset = "0";
|
||||
element.appendChild(overlayLayer);
|
||||
}
|
||||
|
||||
const overlayOpacity =
|
||||
bgOverlay != null ? Number(bgOverlay) / 100 : 0;
|
||||
overlayLayer.style.backgroundColor =
|
||||
overlayOpacity > 0 ? `rgba(0,0,0,${overlayOpacity})` : "";
|
||||
}
|
||||
}, [backgroundStyle, isCustomImage, isPattern, customImageSettings, nightMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||
import {
|
||||
applyColorBackgroundCssVariables,
|
||||
applyCustomImageCssVariables,
|
||||
applyPatternCssVariables,
|
||||
clearCustomImageCssVariables,
|
||||
clearPatternCssVariables,
|
||||
colorizeSvg,
|
||||
hexToRgba,
|
||||
normalizeBgNumber,
|
||||
PATTERN_BACKGROUND_OPACITY,
|
||||
resolveBgType,
|
||||
} from "@/lib/helpers/backgroundUtils";
|
||||
import usePreference from "@/hooks/helpers/usePreference";
|
||||
import {
|
||||
applyCachedThemeToDom,
|
||||
buildThemeCacheFromRestaurant,
|
||||
clearRestaurantBackgroundOverrides,
|
||||
getThemeCache,
|
||||
removeCachedThemeBackground,
|
||||
setThemeCache,
|
||||
svgToDataUrl,
|
||||
themeBackgroundMatches,
|
||||
type CachedRestaurantTheme,
|
||||
} from "@/lib/helpers/themeCache";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
const DEFAULT_MENU_COLOR = "#1E3A8A";
|
||||
|
||||
export type CustomImageSettings = {
|
||||
bgUrl: string;
|
||||
bgBlur: number | null;
|
||||
bgOpacity: number | null;
|
||||
bgOverlay: string | null;
|
||||
};
|
||||
|
||||
type PatternBackgroundContextValue = {
|
||||
isPattern: boolean;
|
||||
isCustomImage: boolean;
|
||||
backgroundStyle: CSSProperties;
|
||||
customImageSettings: CustomImageSettings | null;
|
||||
};
|
||||
|
||||
const PatternBackgroundContext = createContext<PatternBackgroundContextValue>({
|
||||
isPattern: false,
|
||||
isCustomImage: false,
|
||||
backgroundStyle: {},
|
||||
customImageSettings: null,
|
||||
});
|
||||
|
||||
export function PatternBackgroundProvider({ children }: { children: ReactNode }) {
|
||||
const { name: restaurantSlug } = useParams<{ name: string }>();
|
||||
const { state: nightMode } = usePreference("night-mode", false);
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const restaurant = aboutData?.data;
|
||||
const [hydratedCache, setHydratedCache] = useState<CachedRestaurantTheme | null>(null);
|
||||
const [patternImageUrl, setPatternImageUrl] = useState<string | null>(null);
|
||||
|
||||
const bgType = restaurant
|
||||
? resolveBgType(restaurant)
|
||||
: hydratedCache
|
||||
? resolveBgType(hydratedCache)
|
||||
: "color";
|
||||
|
||||
const isPattern = !nightMode && bgType === "pattern";
|
||||
const isCustomImage = !nightMode && bgType === "custom";
|
||||
const menuColor =
|
||||
restaurant?.menuColor || hydratedCache?.menuColor || DEFAULT_MENU_COLOR;
|
||||
const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!restaurantSlug) return;
|
||||
|
||||
const cached = getThemeCache(restaurantSlug);
|
||||
setHydratedCache(cached);
|
||||
|
||||
if (!cached) return;
|
||||
|
||||
applyCachedThemeToDom(cached);
|
||||
if (!nightMode && cached.patternDataUrl) {
|
||||
setPatternImageUrl(cached.patternDataUrl);
|
||||
}
|
||||
}, [nightMode, restaurantSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restaurantSlug) {
|
||||
clearPatternCssVariables();
|
||||
clearCustomImageCssVariables();
|
||||
removeCachedThemeBackground();
|
||||
setPatternImageUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nightMode) {
|
||||
clearPatternCssVariables();
|
||||
clearCustomImageCssVariables();
|
||||
clearRestaurantBackgroundOverrides();
|
||||
setPatternImageUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!restaurant) return;
|
||||
|
||||
const themeBase = buildThemeCacheFromRestaurant(restaurant);
|
||||
const currentBgType = themeBase.bgType;
|
||||
|
||||
if (currentBgType === "color") {
|
||||
clearCustomImageCssVariables();
|
||||
removeCachedThemeBackground();
|
||||
setPatternImageUrl(null);
|
||||
applyColorBackgroundCssVariables(menuColor);
|
||||
setThemeCache(restaurantSlug, { ...themeBase, patternDataUrl: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBgType === "custom") {
|
||||
clearPatternCssVariables();
|
||||
setPatternImageUrl(null);
|
||||
applyCustomImageCssVariables();
|
||||
setThemeCache(restaurantSlug, { ...themeBase, patternDataUrl: null });
|
||||
return;
|
||||
}
|
||||
|
||||
clearCustomImageCssVariables();
|
||||
|
||||
applyPatternCssVariables(menuColor);
|
||||
|
||||
if (!themeBase.bgUrl) {
|
||||
setPatternImageUrl(null);
|
||||
setThemeCache(restaurantSlug, {
|
||||
...themeBase,
|
||||
patternDataUrl: null,
|
||||
});
|
||||
const saved = getThemeCache(restaurantSlug);
|
||||
if (saved) {
|
||||
applyCachedThemeToDom(saved);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getThemeCache(restaurantSlug);
|
||||
const cacheHit =
|
||||
cached &&
|
||||
themeBackgroundMatches(cached, restaurant) &&
|
||||
cached.isPattern &&
|
||||
cached.patternDataUrl;
|
||||
|
||||
if (cacheHit) {
|
||||
// Serve cached pattern immediately, then revalidate in background.
|
||||
setPatternImageUrl(cached.patternDataUrl!);
|
||||
setThemeCache(restaurantSlug, {
|
||||
...themeBase,
|
||||
patternDataUrl: cached.patternDataUrl,
|
||||
});
|
||||
applyCachedThemeToDom(cached);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(themeBase.bgUrl)}`;
|
||||
|
||||
fetch(proxyUrl, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch background pattern");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((svg) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const dataUrl = svgToDataUrl(colorizeSvg(svg, menuColor));
|
||||
if (dataUrl !== cached?.patternDataUrl) {
|
||||
setPatternImageUrl(dataUrl);
|
||||
}
|
||||
|
||||
const nextCache = {
|
||||
...themeBase,
|
||||
patternDataUrl: dataUrl,
|
||||
};
|
||||
setThemeCache(restaurantSlug, nextCache);
|
||||
|
||||
const saved = getThemeCache(restaurantSlug);
|
||||
if (saved) {
|
||||
applyCachedThemeToDom(saved);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && cached?.patternDataUrl) {
|
||||
setPatternImageUrl(cached.patternDataUrl);
|
||||
applyCachedThemeToDom(cached);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [menuColor, nightMode, restaurant, restaurantSlug]);
|
||||
|
||||
const backgroundStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
backgroundColor: baseTint,
|
||||
...(patternImageUrl
|
||||
? {
|
||||
backgroundImage: `url("${patternImageUrl}")`,
|
||||
backgroundRepeat: "repeat",
|
||||
backgroundSize: "auto",
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[baseTint, patternImageUrl],
|
||||
);
|
||||
|
||||
const customImageSettings = useMemo<CustomImageSettings | null>(() => {
|
||||
const src = restaurant?.bgUrl || hydratedCache?.bgUrl;
|
||||
if (!isCustomImage || !src) return null;
|
||||
return {
|
||||
bgUrl: src,
|
||||
bgBlur: normalizeBgNumber(restaurant?.bgBlur ?? hydratedCache?.bgBlur),
|
||||
bgOpacity: normalizeBgNumber(restaurant?.bgOpacity ?? hydratedCache?.bgOpacity),
|
||||
bgOverlay: restaurant?.bgOverlay ?? hydratedCache?.bgOverlay ?? null,
|
||||
};
|
||||
}, [isCustomImage, restaurant, hydratedCache]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ isPattern, isCustomImage, backgroundStyle, customImageSettings }),
|
||||
[backgroundStyle, isPattern, isCustomImage, customImageSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<PatternBackgroundContext.Provider value={value}>
|
||||
{children}
|
||||
</PatternBackgroundContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePatternBackground() {
|
||||
return useContext(PatternBackgroundContext);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import clsx from "clsx";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { Icon, SearchNormal } from "iconsax-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ComboboxOption {
|
||||
id: string;
|
||||
@@ -28,6 +29,12 @@ type Props = {
|
||||
) => void;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
type DropdownPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
function Combobox({
|
||||
title,
|
||||
options,
|
||||
@@ -37,17 +44,53 @@ function Combobox({
|
||||
selectedId,
|
||||
searchable = true,
|
||||
onSelectionChange,
|
||||
className,
|
||||
...props
|
||||
}: Props) {
|
||||
const [expand, setExpand] = useState(expanded);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expand) {
|
||||
setDropdownPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateDropdownPosition();
|
||||
window.addEventListener("scroll", updateDropdownPosition, true);
|
||||
window.addEventListener("resize", updateDropdownPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updateDropdownPosition, true);
|
||||
window.removeEventListener("resize", updateDropdownPosition);
|
||||
};
|
||||
}, [expand, updateDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
||||
setExpand(false);
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
boxRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setExpand(false);
|
||||
};
|
||||
|
||||
if (expand) {
|
||||
@@ -72,12 +115,14 @@ function Combobox({
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
index: number,
|
||||
) => {
|
||||
e.stopPropagation(); // prevent toggle
|
||||
e.stopPropagation();
|
||||
onSelectionChange(e, index);
|
||||
setExpand(false);
|
||||
};
|
||||
|
||||
const selectedOption = options.find((x) => x.id === selectedId);
|
||||
const filteredOptions = options.filter((v) => v.title?.includes(searchValue));
|
||||
|
||||
const activeIcon =
|
||||
selectedOption?.icon &&
|
||||
React.createElement(selectedOption.icon, {
|
||||
@@ -93,7 +138,7 @@ function Combobox({
|
||||
collapse: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
pointerEvents: "none", // disable clicks
|
||||
pointerEvents: "none",
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
@@ -102,7 +147,7 @@ function Combobox({
|
||||
expand: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
pointerEvents: "auto", // re-enable clicks
|
||||
pointerEvents: "auto",
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
@@ -110,13 +155,84 @@ function Combobox({
|
||||
},
|
||||
};
|
||||
|
||||
const dropdown =
|
||||
expand &&
|
||||
dropdownPosition &&
|
||||
typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
data-expand={expand}
|
||||
aria-hidden={!expand}
|
||||
className="fixed text-foreground bg-container rounded-xl border border-border shadow-lg z-9999"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label={`${title} options`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial="collapse"
|
||||
animate="expand"
|
||||
variants={variants}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="w-full flex gap-2 border-b border-border px-3 items-center">
|
||||
<SearchNormal size={16} className="stroke-gray-400" />
|
||||
<input
|
||||
placeholder="جستجو ..."
|
||||
className="w-full outline-none text-sm2 pb-2 pt-3"
|
||||
onChange={searchChanged}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{filteredOptions.map((v) => {
|
||||
const originalIndex = options.findIndex((o) => o.id === v.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => setSelection(e, originalIndex)}
|
||||
key={v.id}
|
||||
data-selected={v.id === selectedId}
|
||||
className="flex gap-3 items-center justify-end px-2 py-2 cursor-pointer hover:bg-current/10 rounded-md last:rounded-b-xl"
|
||||
tabIndex={0}
|
||||
role="option"
|
||||
aria-selected={v.id === selectedId}
|
||||
>
|
||||
{v?.imagePath ? (
|
||||
<span className="icon-delivery shrink-0" aria-hidden />
|
||||
) : (
|
||||
v?.icon &&
|
||||
React.createElement(v.icon, {
|
||||
size: 16,
|
||||
color: "currentColor",
|
||||
className:
|
||||
"inline-block shrink-0 text-primary dark:text-foreground",
|
||||
})
|
||||
)}
|
||||
<span className="text-sm2 tracking-[0.13px] leading-5 flex-1 text-right">
|
||||
{v.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className={clsx("relative", props.className ?? "")}
|
||||
className={clsx("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="flex w-full h-11 items-center justify-end gap-2 px-3 py-4 relative bg-container/29 rounded-xl border border-solid border-border cursor-pointer"
|
||||
tabIndex={0}
|
||||
onClick={toggleExpand}
|
||||
@@ -163,59 +279,7 @@ function Combobox({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
data-expand={expand}
|
||||
aria-hidden={!expand}
|
||||
className="absolute top-full left-0 w-full mt-1 text-foreground bg-container rounded-xl outline outline-solid outline-border shadow-lg z-10"
|
||||
role="listbox"
|
||||
aria-label={`${title} options`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial="collapse"
|
||||
animate={expand ? "expand" : "collapse"}
|
||||
variants={variants}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="w-full flex gap-2 border-b border-border px-3 items-center">
|
||||
<SearchNormal size={16} className="stroke-gray-400" />
|
||||
<input
|
||||
placeholder="جستجو ..."
|
||||
className="w-full outline-none text-sm2 pb-2 pt-3"
|
||||
onChange={searchChanged}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1">
|
||||
{options
|
||||
.filter((v) => v.title?.includes(searchValue))
|
||||
.map((v, i) => (
|
||||
<div
|
||||
onClick={(e) => setSelection(e, i)}
|
||||
key={v.id}
|
||||
data-selected={v.id === selectedId}
|
||||
className="flex gap-3 items-center justify-end px-2 py-1 cursor-pointer hover:bg-current/10 rounded-md last:rounded-b-xl"
|
||||
tabIndex={0}
|
||||
role="option"
|
||||
aria-selected
|
||||
>
|
||||
{v?.imagePath ? (
|
||||
<span className="icon-delivery mr-1" aria-hidden />
|
||||
) : (
|
||||
v?.icon &&
|
||||
React.createElement(v.icon, {
|
||||
size: 16,
|
||||
color: "currentColor",
|
||||
className:
|
||||
"inline-block mr-1 text-primary dark:text-foreground",
|
||||
})
|
||||
)}
|
||||
<span className="text-sm2 tracking-[0.13px] leading-5 w-full mt-1">
|
||||
{v.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
{dropdown}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import Tooltip from '../utils/Tooltip';
|
||||
import clsx from 'clsx';
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import Tooltip from "../utils/Tooltip";
|
||||
|
||||
type Props = {
|
||||
htmlFor: string;
|
||||
labelText?: React.ReactNode;
|
||||
value?: string;
|
||||
valid?: boolean
|
||||
inputClassName?: string;
|
||||
htmlFor: string;
|
||||
labelText?: React.ReactNode;
|
||||
value?: string;
|
||||
valid?: boolean;
|
||||
inputClassName?: string;
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "id">;
|
||||
|
||||
const InputField = React.forwardRef<HTMLInputElement, Props>(({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => {
|
||||
return (
|
||||
<div spellCheck={false} className={`${className} ${valid ? "border-border" : "border-invalid"} h-11 inline-flex relative border px-3 w-full rounded-normal group focus-within:border`}>
|
||||
<label className="absolute start-2 -top-2.5 px-2 text-[12px] text-foreground bg-container" htmlFor={htmlFor}>
|
||||
{labelText}
|
||||
</label>
|
||||
|
||||
const InputField = React.forwardRef<HTMLInputElement, Props>(
|
||||
({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => {
|
||||
return (
|
||||
<div
|
||||
spellCheck={false}
|
||||
className={`${className} ${valid ? 'border-border' : 'border-invalid'} h-11 inline-flex relative border px-3 w-full rounded-normal group focus-within:border`}>
|
||||
<label
|
||||
className='absolute start-2 -top-2.5 px-2 text-[12px] text-foreground bg-container'
|
||||
htmlFor={htmlFor}>
|
||||
{labelText}
|
||||
</label>
|
||||
<Tooltip content={inputProps["aria-errormessage"]} hidden={!inputProps["aria-errormessage"]}>
|
||||
<input
|
||||
ref={ref}
|
||||
onChange={onChange}
|
||||
className={clsx(inputProps["aria-errormessage"] && "text-red-300!", "py-2.5 pt-3.5 text-sm2 w-full outline-0 leading-6!", inputClassName)}
|
||||
name={htmlFor}
|
||||
{...inputProps}
|
||||
/>
|
||||
{children}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
<Tooltip content={inputProps['aria-errormessage']} hidden={!inputProps['aria-errormessage']}>
|
||||
<input
|
||||
ref={ref}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
inputProps['aria-errormessage'] && 'text-red-300!',
|
||||
'py-2.5 pt-3.5 text-sm2 w-full outline-0 leading-6!',
|
||||
inputClassName
|
||||
)}
|
||||
name={htmlFor}
|
||||
{...inputProps} />
|
||||
{children}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
InputField.displayName = "InputField";
|
||||
|
||||
InputField.displayName = 'InputField'
|
||||
|
||||
export default InputField
|
||||
export default InputField;
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import SearchIcon from '../icons/SearchIcon'
|
||||
import clsx from 'clsx'
|
||||
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import SearchIcon from "../icons/SearchIcon";
|
||||
|
||||
interface SearchboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'results'> {
|
||||
placeholder?: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
interface SearchboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "results"> {
|
||||
placeholder?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export default function SearchBox({
|
||||
className,
|
||||
value,
|
||||
placeholder = '',
|
||||
onChange,
|
||||
...props
|
||||
}: SearchboxProps) {
|
||||
export default function SearchBox({ className, value, placeholder = "", onChange, ...props }: SearchboxProps) {
|
||||
const { name } = useParams<{ name?: string }>();
|
||||
const { data: about } = useGetAbout();
|
||||
const showAiLink = !!name && !!about?.data?.enableAiChat;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-container inline-flex rounded-xl px-4 h-10 w-full items-center content-center',
|
||||
className
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className={glassSurfaceFlat("inline-flex rounded-xl px-4 h-10 w-full items-center content-center", className)}>
|
||||
<SearchIcon stroke="#8C90A3" />
|
||||
<input
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
className="text-[#8C90A3] pt-1 block outline-none border-none focus:ring-0 focus:outline-none px-1.75 h-full items-center w-full text-xs!"
|
||||
type="search"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{showAiLink && (
|
||||
<Link href={`/${name}/ai`} aria-label="AI" className={glassSurfaceFlat("inline-flex items-center justify-center rounded-xl w-10 h-10 shrink-0")}>
|
||||
<img src={"/assets/images/logo_ai2.svg"} alt="logo_ai" className="w-5" />
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<SearchIcon stroke='#8C90A3' />
|
||||
<input
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
className='text-[#8C90A3] pt-1 block outline-none border-none focus:ring-0 focus:outline-none px-[7px] h-full items-center w-full text-xs!'
|
||||
type='search'
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
@@ -8,10 +9,10 @@ type Props = {
|
||||
|
||||
function CategoryItemRenderer({ children, ...rest }: Props) {
|
||||
return (
|
||||
<div className={`${rest.className} cursor-pointer transition-all duration-200 ease-out gradient-border overflow-hidden rounded-xl backdrop-blur-md bg-container/40`}>
|
||||
<div className={glassSurface(rest.className, 'cursor-pointer transition-[background-color,box-shadow,opacity] duration-200 ease-out rounded-xl outline-none [-webkit-tap-highlight-color:transparent]')}>
|
||||
<div
|
||||
{...rest}
|
||||
className="rounded-normal w-[88px] h-[88px] flex flex-col justify-center items-center gap-2"
|
||||
className="rounded-normal w-[108px] h-[88px] flex flex-col justify-center items-center gap-2 overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
@@ -8,10 +9,10 @@ type Props = {
|
||||
|
||||
function CategorySmallItemRenderer({ children, ...rest }: Props) {
|
||||
return (
|
||||
<div className={`${rest.className} cursor-pointer transition-all duration-200 ease-out gradient-border overflow-hidden rounded-xl backdrop-blur-md bg-container/40 dark:bg-background`}>
|
||||
<div className={glassSurface(rest.className, 'cursor-pointer transition-[background-color,box-shadow,opacity] duration-200 ease-out rounded-xl outline-none [-webkit-tap-highlight-color:transparent]')}>
|
||||
<div
|
||||
{...rest}
|
||||
className="rounded-normal h-[44px] flex flex-row justify-center items-center p-2.5 gap-2"
|
||||
className="rounded-normal h-[44px] flex flex-row justify-center items-center p-2.5 gap-2 overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCart } from "@/app/[name]/(Main)/cart/hook/useCart";
|
||||
import type { Food, FoodImage } from "@/app/[name]/(Main)/types/Types";
|
||||
import MinusIcon from "@/components/icons/MinusIcon";
|
||||
import PlusIcon from "@/components/icons/PlusIcon";
|
||||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -52,9 +51,7 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
||||
if (!content) return "";
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((item) => item && typeof item === "string")
|
||||
.join("، ");
|
||||
return content.filter((item) => item && typeof item === "string").join("، ");
|
||||
}
|
||||
|
||||
return "";
|
||||
@@ -75,15 +72,9 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
||||
return basePrice;
|
||||
}, [food.price, food.discount]);
|
||||
|
||||
const formattedPrice = useMemo(
|
||||
() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"),
|
||||
[finalPrice],
|
||||
);
|
||||
const formattedPrice = useMemo(() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"), [finalPrice]);
|
||||
|
||||
const formattedOriginalPrice = useMemo(
|
||||
() => (food.price ? food.price.toLocaleString("fa-IR") : "0"),
|
||||
[food.price],
|
||||
);
|
||||
const formattedOriginalPrice = useMemo(() => (food.price ? food.price.toLocaleString("fa-IR") : "0"), [food.price]);
|
||||
|
||||
const hasDiscount = useMemo(() => (food.discount || 0) > 0, [food.discount]);
|
||||
|
||||
@@ -101,90 +92,50 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
||||
const foodDetailUrl = `/${name}/${food.id}?category=${food.category}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-full h-full items-center",
|
||||
!isAvailable && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-4 w-full h-full items-center">
|
||||
<Link href={foodDetailUrl} className="cursor-pointer">
|
||||
<img
|
||||
className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 max-h-28 object-cover"
|
||||
src={imageSrc}
|
||||
height={112}
|
||||
width={112}
|
||||
alt={foodName}
|
||||
loading="lazy"
|
||||
onError={() => setImageSrc(fallbackImage)}
|
||||
/>
|
||||
<img className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 max-h-28 object-cover" src={imageSrc} height={112} width={112} alt={foodName} loading="lazy" onError={() => setImageSrc(fallbackImage)} />
|
||||
</Link>
|
||||
<div className="w-full inline-flex flex-col justify-between min-w-0">
|
||||
<Link href={foodDetailUrl} className="cursor-pointer min-w-0">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word">
|
||||
{foodName}
|
||||
</div>
|
||||
{(foodContent || foodDescription) && (
|
||||
<div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-2 wrap-break-word overflow-hidden">
|
||||
{foodContent || foodDescription}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word">{foodName}</div>
|
||||
{(foodContent || foodDescription) && <div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-2 wrap-break-word overflow-hidden">{foodContent || foodDescription}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="inline-flex mt-2 gap-2 justify-between w-full items-center">
|
||||
<div className="w-full flex flex-col gap-1" dir="ltr">
|
||||
<div className="inline-flex mt-2 gap-2 justify-between w-full items-start">
|
||||
<div className="w-full flex flex-col gap-1 items-end" dir="ltr">
|
||||
{hasDiscount ? (
|
||||
<>
|
||||
<span className="text-xs text-disabled-text line-through">
|
||||
{formattedOriginalPrice} T
|
||||
</span>
|
||||
<span className="text-sm font-medium text-primary dark:text-foreground">
|
||||
{formattedPrice} T
|
||||
</span>
|
||||
<span className="text-xs text-disabled-text line-through">{formattedOriginalPrice} T</span>
|
||||
<span className="text-sm font-medium text-black dark:text-white">{formattedPrice} T</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm mt-1 text-right">
|
||||
{formattedPrice} T
|
||||
</span>
|
||||
<span className="text-sm text-right">{formattedPrice} T</span>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
whileTap={isAvailable ? { scale: 1.05 } : undefined}
|
||||
className="bg-background active:drop-shadow-xs max-w-[115px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2 relative overflow-hidden"
|
||||
className="bg-background pattern-secondary-bg active:drop-shadow-xs max-w-28.75 rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2 relative overflow-hidden"
|
||||
>
|
||||
{quantity <= 0 ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable}
|
||||
className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<PlusIcon />
|
||||
<div className="text-sm2 pt-0.5 font-normal text-foreground">
|
||||
افزودن
|
||||
</div>
|
||||
<button onClick={handleAddToCart} disabled={!isAvailable} className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none">
|
||||
{isAvailable && <PlusIcon />}
|
||||
<div className="text-sm2 pt-0.5 font-normal text-foreground">{isAvailable ? "افزودن" : "عدم موجودی"}</div>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable}
|
||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors disabled:pointer-events-none"
|
||||
>
|
||||
<PlusIcon className="text-foreground" />
|
||||
</button>
|
||||
<motion.div
|
||||
key={quantity}
|
||||
initial={{ scale: 1 }}
|
||||
animate={{ scale: [1.2, 0.95, 1] }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm2 pt-0.5 font-semibold"
|
||||
>
|
||||
<motion.div key={quantity} initial={{ scale: 1 }} animate={{ scale: [1.2, 0.95, 1] }} transition={{ duration: 0.3 }} className="text-sm2 pt-0.5 font-semibold">
|
||||
{quantity}
|
||||
</motion.div>
|
||||
<button
|
||||
onClick={handleRemoveFromCart}
|
||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
|
||||
>
|
||||
<button onClick={handleRemoveFromCart} className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors">
|
||||
<MinusIcon className="text-foreground" />
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { glassSurface } from "@/lib/styles/glassSurface";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -8,10 +9,7 @@ type Props = {
|
||||
|
||||
function MenuItemRendererComponent({ children, ...rest }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`${rest.className} box-shadow-normal !pb-4 py-4 transition-all duration-200 ease-out w-full bg-container rounded-3xl px-4 flex items-center justify-between gap-4`}>
|
||||
|
||||
<div {...rest} className={glassSurface(rest.className, "pb-4! py-4 transition-all duration-200 ease-out w-full rounded-3xl px-4 flex items-center justify-between gap-4")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
+386
-423
@@ -1,465 +1,428 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import SideMenuItem from './SideMenuItem';
|
||||
import NoteBoardIcon from '../icons/NoteBoardIcon';
|
||||
import BlurredOverlayContainer from '../overlays/BlurredOverlayContainer';
|
||||
import NightModeSwitch from '../button/NightModeSwitch';
|
||||
import Button from '../button/PrimaryButton';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarSearch, Cup, DirectboxReceive, DirectInbox, DocumentCopy, Game, Icon, Instagram, Like1, Login, LogoutCurve, Notification, Receipt1, Setting2, Share, TicketDiscount, Whatsapp } from 'iconsax-react';
|
||||
import TelegramIcon from '../icons/TelegramIcon';
|
||||
import Modal from '../utils/Modal';
|
||||
import LogoutPrompt from '@/features/general/LogoutPrompt';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
|
||||
import Image from 'next/image';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { toast, toastLoginRequired } from '../Toast';
|
||||
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import LogoutPrompt from "@/features/general/LogoutPrompt";
|
||||
import useToggle from "@/hooks/helpers/useToggle";
|
||||
import { glassSurface } from "@/lib/styles/glassSurface";
|
||||
import clsx from "clsx";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import {
|
||||
CalendarSearch,
|
||||
Cup,
|
||||
DirectboxReceive,
|
||||
DirectInbox,
|
||||
DocumentCopy,
|
||||
Game,
|
||||
Icon,
|
||||
Instagram,
|
||||
Like1,
|
||||
Login,
|
||||
LogoutCurve,
|
||||
MessageProgramming,
|
||||
Notification,
|
||||
Receipt1,
|
||||
Setting2,
|
||||
Share,
|
||||
TicketDiscount,
|
||||
Whatsapp,
|
||||
} from "iconsax-react";
|
||||
import Image from "next/image";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import NightModeSwitch from "../button/NightModeSwitch";
|
||||
import Button from "../button/PrimaryButton";
|
||||
import NoteBoardIcon from "../icons/NoteBoardIcon";
|
||||
import TelegramIcon from "../icons/TelegramIcon";
|
||||
import BlurredOverlayContainer from "../overlays/BlurredOverlayContainer";
|
||||
import { toast, toastLoginRequired } from "../Toast";
|
||||
import Modal from "../utils/Modal";
|
||||
import SideMenuItem from "./SideMenuItem";
|
||||
|
||||
type MenuItemType = {
|
||||
href: string | undefined;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
auth?: boolean;
|
||||
guestOnly?: boolean;
|
||||
premiumOnly?: boolean;
|
||||
href: string | undefined;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
badge?: string;
|
||||
auth?: boolean;
|
||||
guestOnly?: boolean;
|
||||
premiumOnly?: boolean;
|
||||
};
|
||||
|
||||
const menuItems: Array<Array<MenuItemType>> = [
|
||||
[
|
||||
{ href: 'notifications', title: 'Notifications', icon: Notification },
|
||||
{ href: 'transactions/discount/club', title: 'CustomerClub', icon: Cup, premiumOnly: true },
|
||||
{ href: 'transactions/discount', title: 'Discounts', icon: TicketDiscount, premiumOnly: true },
|
||||
{ href: 'order/history', title: 'Orders', icon: CalendarSearch, premiumOnly: true },
|
||||
{ href: 'transactions', title: 'Transactions', icon: Receipt1, premiumOnly: true },
|
||||
{ href: 'game', title: 'Games', icon: Game },
|
||||
{ href: '?share', title: 'ShareWithFriends', icon: Like1 },
|
||||
{ href: 'report', title: 'ReportProblem', icon: NoteBoardIcon },
|
||||
{ href: '?installpwa', title: 'InstallApp', icon: DirectboxReceive }
|
||||
],
|
||||
[],
|
||||
[
|
||||
{ auth: true, href: 'profile/settings', title: 'Preferences', icon: Setting2 },
|
||||
{ auth: true, href: '?logout', title: 'Logout', icon: LogoutCurve },
|
||||
{ guestOnly: true, href: 'auth', title: 'LoginSignup', icon: Login }
|
||||
]
|
||||
[
|
||||
{ href: "notifications", title: "Notifications", icon: Notification },
|
||||
{ href: "transactions/discount/club", title: "CustomerClub", icon: Cup, premiumOnly: true },
|
||||
{ href: "transactions/discount", title: "Discounts", icon: TicketDiscount, premiumOnly: true },
|
||||
{ href: "order/history", title: "Orders", icon: CalendarSearch, premiumOnly: true },
|
||||
{ href: "transactions", title: "Transactions", icon: Receipt1, premiumOnly: true },
|
||||
{ href: "game", title: "Games", icon: Game },
|
||||
{ href: "ai", title: "AI", icon: MessageProgramming, badge: "AIBadge" },
|
||||
{ href: "?share", title: "ShareWithFriends", icon: Like1 },
|
||||
{ href: "report", title: "ReportProblem", icon: NoteBoardIcon },
|
||||
{ href: "?installpwa", title: "InstallApp", icon: DirectboxReceive },
|
||||
],
|
||||
[],
|
||||
[
|
||||
{ auth: true, href: "profile/settings", title: "Preferences", icon: Setting2 },
|
||||
{ auth: true, href: "?logout", title: "Logout", icon: LogoutCurve },
|
||||
{ guestOnly: true, href: "auth", title: "LoginSignup", icon: Login },
|
||||
],
|
||||
];
|
||||
|
||||
type Props = {
|
||||
menuState: boolean;
|
||||
toggleMenuState: React.MouseEventHandler<HTMLDivElement> | undefined;
|
||||
nightModeState: boolean;
|
||||
togglenightModeState: React.MouseEventHandler<HTMLDivElement> | undefined;
|
||||
menuState: boolean;
|
||||
toggleMenuState: React.MouseEventHandler<HTMLDivElement> | undefined;
|
||||
nightModeState: boolean;
|
||||
togglenightModeState: React.MouseEventHandler<HTMLDivElement> | undefined;
|
||||
};
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
const isIOS = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
if (typeof window === "undefined") return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
};
|
||||
|
||||
const isStandalone = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const isIOSStandalone =
|
||||
('standalone' in window.navigator) &&
|
||||
(window.navigator as { standalone?: boolean }).standalone === true;
|
||||
const isIOSStandalone = "standalone" in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true;
|
||||
|
||||
const isDisplayModeStandalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.matchMedia('(display-mode: fullscreen)').matches;
|
||||
const isDisplayModeStandalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches;
|
||||
|
||||
return isIOSStandalone || isDisplayModeStandalone;
|
||||
return isIOSStandalone || isDisplayModeStandalone;
|
||||
};
|
||||
|
||||
function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) {
|
||||
const menuStateMemo = useMemo(() => menuState, [menuState]);
|
||||
const closeRef = useRef<HTMLDivElement>(null);
|
||||
const { state: logoutModal, toggle: toggleLogoutModal } = useToggle();
|
||||
const { state: shareModal, toggle: toggleShareModal } = useToggle();
|
||||
const { state: installPwaModal, toggle: toggleInstallPwaModal } = useToggle();
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isIOSDevice, setIsIOSDevice] = useState(false);
|
||||
const [isPWAInstalled, setIsPWAInstalled] = useState(false);
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const { data: profile, isSuccess } = useGetProfile();
|
||||
const profileData = profile?.data;
|
||||
const isLoggedIn = isSuccess && profileData;
|
||||
const isPremium = aboutData?.data?.plan === "premium";
|
||||
const router = useRouter();
|
||||
|
||||
const menuStateMemo = useMemo(() => menuState, [menuState]);
|
||||
const closeRef = useRef<HTMLDivElement>(null);
|
||||
const { state: logoutModal, toggle: toggleLogoutModal } = useToggle();
|
||||
const { state: shareModal, toggle: toggleShareModal } = useToggle();
|
||||
const { state: installPwaModal, toggle: toggleInstallPwaModal } = useToggle();
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isIOSDevice, setIsIOSDevice] = useState(false);
|
||||
const [isPWAInstalled, setIsPWAInstalled] = useState(false);
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const { data: profile, isSuccess } = useGetProfile();
|
||||
const profileData = profile?.data;
|
||||
const isLoggedIn = isSuccess && profileData;
|
||||
const isPremium = aboutData?.data?.plan === 'premium';
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { name } = params;
|
||||
const pathname = usePathname();
|
||||
const { t: tMenu } = useTranslation("common", {
|
||||
keyPrefix: "SideMenu",
|
||||
});
|
||||
const { t: tShareModal } = useTranslation("common", {
|
||||
keyPrefix: "ShareModal",
|
||||
});
|
||||
const { t: tInstallPwaModal } = useTranslation("common", {
|
||||
keyPrefix: "InstallPwaModal",
|
||||
});
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: { x: "100%", transition: { duration: 0.1, ease: "easeInOut" } },
|
||||
visible: { x: 0, transition: { delay: 0.075, duration: 0.1, ease: "easeInOut" } },
|
||||
};
|
||||
|
||||
const params = useParams();
|
||||
const { name } = params;
|
||||
const pathname = usePathname();
|
||||
const { t: tMenu } = useTranslation('common', {
|
||||
keyPrefix: 'SideMenu'
|
||||
});
|
||||
const { t: tShareModal } = useTranslation('common', {
|
||||
keyPrefix: 'ShareModal'
|
||||
});
|
||||
const { t: tInstallPwaModal } = useTranslation('common', {
|
||||
keyPrefix: 'InstallPwaModal'
|
||||
});
|
||||
useEffect(() => {
|
||||
const ios = isIOS();
|
||||
const standalone = isStandalone();
|
||||
setIsIOSDevice(ios);
|
||||
setIsPWAInstalled(standalone);
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: { x: '100%', transition: { duration: 0.1, ease: 'easeInOut' } },
|
||||
visible: { x: 0, transition: { delay: 0.075, duration: 0.1, ease: 'easeInOut' } }
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const ios = isIOS();
|
||||
const standalone = isStandalone();
|
||||
setIsIOSDevice(ios);
|
||||
setIsPWAInstalled(standalone);
|
||||
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setDeferredPrompt(null);
|
||||
setIsPWAInstalled(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstallPWA = async () => {
|
||||
if (!deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
setDeferredPrompt(null);
|
||||
toggleInstallPwaModal();
|
||||
}
|
||||
const handleAppInstalled = () => {
|
||||
setDeferredPrompt(null);
|
||||
setIsPWAInstalled(true);
|
||||
};
|
||||
|
||||
const getShareUrl = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.location.href;
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", handleAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async (showToast: boolean = true) => {
|
||||
try {
|
||||
const url = getShareUrl();
|
||||
await navigator.clipboard.writeText(url);
|
||||
if (showToast) {
|
||||
toast('لینک با موفقیت کپی شد', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (showToast) {
|
||||
toast('خطا در کپی کردن لینک', 'error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
await copyToClipboard();
|
||||
toggleShareModal();
|
||||
};
|
||||
|
||||
const handleShareTelegram = () => {
|
||||
const url = encodeURIComponent(getShareUrl());
|
||||
window.open(`https://t.me/share/url?url=${url}`, '_blank');
|
||||
};
|
||||
|
||||
const handleShareWhatsApp = () => {
|
||||
const url = encodeURIComponent(getShareUrl());
|
||||
const text = encodeURIComponent('اپلیکیشن ما را ببینید!');
|
||||
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
|
||||
};
|
||||
|
||||
const handleShareInstagram = async () => {
|
||||
const success = await copyToClipboard(false);
|
||||
if (success) {
|
||||
toast('لینک کپی شد. لطفاً آن را در اینستاگرام به اشتراک بگذارید', 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn) {
|
||||
toastLoginRequired(() => router.push(`/${name}/auth`), 'info');
|
||||
} else {
|
||||
router.push(`/${name}/notifications`);
|
||||
}
|
||||
};
|
||||
|
||||
const hrefOnClicks = [
|
||||
{ href: '?logout', handler: toggleLogoutModal },
|
||||
{ href: '?share', handler: toggleShareModal },
|
||||
{ href: '?installpwa', handler: toggleInstallPwaModal },
|
||||
{ href: 'notifications', handler: handleNotificationClick }
|
||||
];
|
||||
|
||||
const renderMenu = () => {
|
||||
return (
|
||||
<section className="flex-1 overflow-y-scroll noscrollbar pt-6 pb-8 flex flex-col justify-between md:w-[200px] xl:w-[250px]">
|
||||
{
|
||||
aboutData?.data?.logo && (
|
||||
<div >
|
||||
<Image className='mx-auto' src={aboutData?.data?.logo} alt='logo' width={110} height={100} unoptimized />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='mt-4'>
|
||||
<header className="px-5 md:px-6 xl:px-12">
|
||||
<h6 className="text-start font-bold text-sm text-menu-header mt-2 mb-[19px] dark:text-disabled-text">منو</h6>
|
||||
</header>
|
||||
|
||||
<nav aria-label={tMenu('NavAriaLabel')}>
|
||||
<ul>
|
||||
{menuItems[0]
|
||||
.filter(item => {
|
||||
if (item.auth && !isSuccess) return false;
|
||||
if (item.guestOnly && isSuccess) return false;
|
||||
if (item.href === '?installpwa' && isPWAInstalled) return false;
|
||||
if (item.premiumOnly && !isPremium) return false;
|
||||
return true;
|
||||
})
|
||||
.map(({ icon: Icon, ...item }, index) => {
|
||||
const href = `/${name}/${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={index} className="mt-4 h-6">
|
||||
<SideMenuItem
|
||||
className={clsx(isActive && 'text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!', 'border-r-4!')}
|
||||
href={href ?? ''}
|
||||
title={tMenu(item.title)}
|
||||
onClick={
|
||||
typeof item.href === 'string'
|
||||
? hrefOnClicks.find((i) => i.href === item.href)?.handler
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
<Icon
|
||||
variant={isActive ? 'Bold' : 'Linear'}
|
||||
className={clsx(
|
||||
isActive ? 'text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200' : 'stroke-icon-deactive text-icon-deactive'
|
||||
)}
|
||||
size={20} width={20} height={20} />
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section aria-label={tMenu('ControlsAriaLabel')}>
|
||||
<ul className="flex flex-col pt-16">
|
||||
{menuItems[2]
|
||||
.filter(item => {
|
||||
if (item.auth && !isSuccess) return false;
|
||||
if (item.guestOnly && isSuccess) return false;
|
||||
return true;
|
||||
})
|
||||
.map(({ icon: Icon, ...item }, index) => {
|
||||
const href = `/${name}/${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={index} className="mt-4 h-6 w-full">
|
||||
<SideMenuItem
|
||||
key={index}
|
||||
href={href ?? ''}
|
||||
className={clsx(isActive && 'text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!', 'border-r-4!')}
|
||||
onClick={
|
||||
typeof item.href === 'string'
|
||||
? hrefOnClicks.find((i) => i.href === item.href)?.handler
|
||||
: undefined
|
||||
}
|
||||
title={tMenu(item.title)}
|
||||
icon={
|
||||
<Icon
|
||||
variant={isActive ? 'Bold' : 'Linear'}
|
||||
className={clsx(
|
||||
isActive ? 'text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200' : 'stroke-icon-deactive text-icon-deactive'
|
||||
)}
|
||||
size={20} width={20} height={20} />
|
||||
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className="mt-4 h-6">
|
||||
<SideMenuItem
|
||||
className='pr-7'
|
||||
href={''}
|
||||
title={''}
|
||||
icon={<></>}>
|
||||
<NightModeSwitch
|
||||
checked={nightModeState}
|
||||
onClick={togglenightModeState}
|
||||
/>
|
||||
</SideMenuItem>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
const handleInstallPWA = async () => {
|
||||
if (!deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setDeferredPrompt(null);
|
||||
toggleInstallPwaModal();
|
||||
}
|
||||
};
|
||||
|
||||
const getShareUrl = () => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.location.href;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (showToast: boolean = true) => {
|
||||
try {
|
||||
const url = getShareUrl();
|
||||
await navigator.clipboard.writeText(url);
|
||||
if (showToast) {
|
||||
toast("لینک با موفقیت کپی شد", "success");
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (showToast) {
|
||||
toast("خطا در کپی کردن لینک", "error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
await copyToClipboard();
|
||||
toggleShareModal();
|
||||
};
|
||||
|
||||
const handleShareTelegram = () => {
|
||||
const url = encodeURIComponent(getShareUrl());
|
||||
window.open(`https://t.me/share/url?url=${url}`, "_blank");
|
||||
};
|
||||
|
||||
const handleShareWhatsApp = () => {
|
||||
const url = encodeURIComponent(getShareUrl());
|
||||
const text = encodeURIComponent("اپلیکیشن ما را ببینید!");
|
||||
window.open(`https://wa.me/?text=${text}%20${url}`, "_blank");
|
||||
};
|
||||
|
||||
const handleShareInstagram = async () => {
|
||||
const success = await copyToClipboard(false);
|
||||
if (success) {
|
||||
toast("لینک کپی شد. لطفاً آن را در اینستاگرام به اشتراک بگذارید", "info");
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn) {
|
||||
toastLoginRequired(() => router.push(`/${name}/auth`), "info");
|
||||
} else {
|
||||
router.push(`/${name}/notifications`);
|
||||
}
|
||||
};
|
||||
|
||||
const hrefOnClicks = [
|
||||
{ href: "?logout", handler: toggleLogoutModal },
|
||||
{ href: "?share", handler: toggleShareModal },
|
||||
{ href: "?installpwa", handler: toggleInstallPwaModal },
|
||||
{ href: "notifications", handler: handleNotificationClick },
|
||||
];
|
||||
|
||||
const renderMenu = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='w-full' ref={closeRef} onClick={toggleMenuState}>
|
||||
<aside aria-label={tMenu('AriaLabel')}>
|
||||
<BlurredOverlayContainer className='xl:hidden!' bgOpacity={40} visible={menuStateMemo} outDelay={150} inDuration={200}>
|
||||
<motion.nav
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
initial="hidden"
|
||||
animate={menuState ? 'visible' : 'hidden'}
|
||||
variants={variants}
|
||||
data-visible={menuState}
|
||||
data-isvisible={menuStateMemo}
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="fixed top-0 bottom-0 right-0 bg-container w-[200px] h-dvh flex flex-col z-40 overflow-clip not-xl:rounded-s-none!"
|
||||
>
|
||||
{renderMenu()}
|
||||
</motion.nav>
|
||||
</BlurredOverlayContainer>
|
||||
<nav
|
||||
className="hidden fixed top-4 bottom-4 right-4 bg-container xl:w-[250px] xl:flex flex-col z-40 overflow-clip xl:rounded-4xl"
|
||||
>
|
||||
{renderMenu()}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
<section className="flex-1 overflow-y-scroll noscrollbar pt-6 pb-8 flex flex-col justify-between md:w-50 xl:w-62.5">
|
||||
{aboutData?.data?.logo && (
|
||||
<div>
|
||||
<Image className="mx-auto" src={aboutData?.data?.logo} alt="logo" width={110} height={100} unoptimized />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<header className="px-5 md:px-6 xl:px-12">
|
||||
<h6 className="text-start font-bold text-sm text-menu-header mt-2 mb-4.75 dark:text-disabled-text">منو</h6>
|
||||
</header>
|
||||
|
||||
{/* Logout Modal */}
|
||||
<LogoutPrompt
|
||||
visible={logoutModal}
|
||||
onClick={toggleLogoutModal}
|
||||
slug={name as string}
|
||||
/>
|
||||
<nav aria-label={tMenu("NavAriaLabel")}>
|
||||
<ul>
|
||||
{menuItems[0]
|
||||
.filter((item) => {
|
||||
if (item.auth && !isSuccess) return false;
|
||||
if (item.guestOnly && isSuccess) return false;
|
||||
if (item.href === "?installpwa" && isPWAInstalled) return false;
|
||||
if (item.premiumOnly && !isPremium) return false;
|
||||
if (item.href === "ai" && !aboutData?.data?.enableAiChat) return false;
|
||||
return true;
|
||||
})
|
||||
.map(({ icon: Icon, ...item }, index) => {
|
||||
const href = `/${name}/${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={index} className={clsx("mt-4", item.badge ? "h-8" : "h-6")}>
|
||||
<SideMenuItem
|
||||
className={clsx(isActive && "text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!", "border-r-4!")}
|
||||
href={href ?? ""}
|
||||
title={tMenu(item.title)}
|
||||
badge={item.badge ? tMenu(item.badge) : undefined}
|
||||
onClick={typeof item.href === "string" ? hrefOnClicks.find((i) => i.href === item.href)?.handler : undefined}
|
||||
icon={
|
||||
<Icon
|
||||
variant={isActive ? "Bold" : "Linear"}
|
||||
className={clsx(isActive ? "text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200" : "stroke-icon-deactive text-icon-deactive")}
|
||||
size={20}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Share modal */}
|
||||
<Modal
|
||||
visible={shareModal}
|
||||
onClick={toggleShareModal}
|
||||
>
|
||||
<h2 className="text-base font-medium">
|
||||
<Share className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||
{tShareModal('Heading')}
|
||||
</h2>
|
||||
<p className="text-xs font-medium mt-6">
|
||||
{tShareModal('Description')}
|
||||
</p>
|
||||
|
||||
<Button onClick={handleCopyLink} className="text-sm mt-8">
|
||||
<DocumentCopy className="inline-block ml-2 mb-0.5 stroke-container dark:stroke-foreground" size={20} />
|
||||
<span className="font-light">{tShareModal('ButtonCopy')}</span>
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-3 gap-5.5 mt-6 px-12">
|
||||
<button
|
||||
onClick={handleShareTelegram}
|
||||
className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<TelegramIcon width={24} height={24} className="mb-1.5 text-primary dark:text-foreground" />
|
||||
<span className='text-xs2 font-medium'>Telegram</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShareWhatsApp}
|
||||
className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Whatsapp size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||
<span className='text-xs2 font-medium'>WhatsApp</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShareInstagram}
|
||||
className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Instagram size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||
<span className='text-xs2 font-medium'>Instagram</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Install PWA modal */}
|
||||
<Modal
|
||||
visible={installPwaModal}
|
||||
onClick={toggleInstallPwaModal}
|
||||
>
|
||||
<h2 className="text-base font-medium">
|
||||
<DirectInbox className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||
{tInstallPwaModal('Heading')}
|
||||
</h2>
|
||||
{isPWAInstalled ? (
|
||||
<p className="mt-6 text-xs font-medium text-center text-foreground/80 dark:text-neutral-200">
|
||||
{tInstallPwaModal('AlreadyInstalled')}
|
||||
</p>
|
||||
) : isIOSDevice ? (
|
||||
<div className="mt-5 rounded-3xl bg-primary/5 px-4 py-5 backdrop-blur-sm dark:bg-white/5">
|
||||
<p className="text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">
|
||||
{tInstallPwaModal('IOSDescription')}
|
||||
</p>
|
||||
<ol className="mt-5 space-y-3 text-xs font-medium rtl:text-right">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">
|
||||
۱
|
||||
</span>
|
||||
<span>{tInstallPwaModal('IOSStep1')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">
|
||||
۲
|
||||
</span>
|
||||
<span>{tInstallPwaModal('IOSStep2')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">
|
||||
۳
|
||||
</span>
|
||||
<span>{tInstallPwaModal('IOSStep3')}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-6 text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">
|
||||
{tInstallPwaModal('Description')}
|
||||
</p>
|
||||
{deferredPrompt ? (
|
||||
<Button onClick={handleInstallPWA} className="mt-8 text-sm">
|
||||
<span className="font-light">{tInstallPwaModal('ButtonOk')}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-4 text-[11px] font-medium leading-relaxed text-center text-foreground/60 dark:text-neutral-300">
|
||||
{tInstallPwaModal('ChromeFallback')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
</>
|
||||
<section aria-label={tMenu("ControlsAriaLabel")}>
|
||||
<ul className="flex flex-col pt-16">
|
||||
{menuItems[2]
|
||||
.filter((item) => {
|
||||
if (item.auth && !isSuccess) return false;
|
||||
if (item.guestOnly && isSuccess) return false;
|
||||
return true;
|
||||
})
|
||||
.map(({ icon: Icon, ...item }, index) => {
|
||||
const href = `/${name}/${item.href}`;
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={index} className="mt-4 h-6 w-full">
|
||||
<SideMenuItem
|
||||
key={index}
|
||||
href={href ?? ""}
|
||||
className={clsx(isActive && "text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!", "border-r-4!")}
|
||||
onClick={typeof item.href === "string" ? hrefOnClicks.find((i) => i.href === item.href)?.handler : undefined}
|
||||
title={tMenu(item.title)}
|
||||
icon={
|
||||
<Icon
|
||||
variant={isActive ? "Bold" : "Linear"}
|
||||
className={clsx(isActive ? "text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200" : "stroke-icon-deactive text-icon-deactive")}
|
||||
size={20}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="mt-4 h-6">
|
||||
<SideMenuItem className="pr-7" href={""} title={""} icon={<></>}>
|
||||
<NightModeSwitch checked={nightModeState} onClick={togglenightModeState} />
|
||||
</SideMenuItem>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full" ref={closeRef} onClick={toggleMenuState}>
|
||||
<aside aria-label={tMenu("AriaLabel")}>
|
||||
<BlurredOverlayContainer className="xl:hidden!" bgOpacity={40} visible={menuStateMemo} outDelay={150} inDuration={200}>
|
||||
<motion.nav
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
initial="hidden"
|
||||
animate={menuState ? "visible" : "hidden"}
|
||||
variants={variants}
|
||||
data-visible={menuState}
|
||||
data-isvisible={menuStateMemo}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="fixed top-0 bottom-0 right-0 bg-container w-50 h-dvh flex flex-col z-40 overflow-clip not-xl:rounded-s-none!"
|
||||
>
|
||||
{renderMenu()}
|
||||
</motion.nav>
|
||||
</BlurredOverlayContainer>
|
||||
<nav className={glassSurface("hidden fixed top-4 bottom-4 right-4 xl:w-62.5 xl:flex flex-col z-40 overflow-clip xl:rounded-4xl")}>{renderMenu()}</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Logout Modal */}
|
||||
<LogoutPrompt visible={logoutModal} onClick={toggleLogoutModal} slug={name as string} />
|
||||
|
||||
{/* Share modal */}
|
||||
<Modal visible={shareModal} onClick={toggleShareModal}>
|
||||
<h2 className="text-base font-medium">
|
||||
<Share className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||
{tShareModal("Heading")}
|
||||
</h2>
|
||||
<p className="text-xs font-medium mt-6">{tShareModal("Description")}</p>
|
||||
|
||||
<Button onClick={handleCopyLink} className="text-sm mt-8">
|
||||
<DocumentCopy className="inline-block ml-2 mb-0.5 stroke-container dark:stroke-foreground" size={20} />
|
||||
<span className="font-light">{tShareModal("ButtonCopy")}</span>
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-3 gap-5.5 mt-6 px-12">
|
||||
<button onClick={handleShareTelegram} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<TelegramIcon width={24} height={24} className="mb-1.5 text-primary dark:text-foreground" />
|
||||
<span className="text-xs2 font-medium">Telegram</span>
|
||||
</button>
|
||||
<button onClick={handleShareWhatsApp} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<Whatsapp size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||
<span className="text-xs2 font-medium">WhatsApp</span>
|
||||
</button>
|
||||
<button onClick={handleShareInstagram} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<Instagram size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||
<span className="text-xs2 font-medium">Instagram</span>
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Install PWA modal */}
|
||||
<Modal visible={installPwaModal} onClick={toggleInstallPwaModal}>
|
||||
<h2 className="text-base font-medium">
|
||||
<DirectInbox className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||
{tInstallPwaModal("Heading")}
|
||||
</h2>
|
||||
{isPWAInstalled ? (
|
||||
<p className="mt-6 text-xs font-medium text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("AlreadyInstalled")}</p>
|
||||
) : isIOSDevice ? (
|
||||
<div className="mt-5 rounded-3xl bg-primary/5 px-4 py-5 backdrop-blur-sm dark:bg-white/5">
|
||||
<p className="text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("IOSDescription")}</p>
|
||||
<ol className="mt-5 space-y-3 text-xs font-medium rtl:text-right">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">۱</span>
|
||||
<span>{tInstallPwaModal("IOSStep1")}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">۲</span>
|
||||
<span>{tInstallPwaModal("IOSStep2")}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[11px] font-bold text-primary">۳</span>
|
||||
<span>{tInstallPwaModal("IOSStep3")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-6 text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("Description")}</p>
|
||||
{deferredPrompt ? (
|
||||
<Button onClick={handleInstallPWA} className="mt-8 text-sm">
|
||||
<span className="font-light">{tInstallPwaModal("ButtonOk")}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-4 text-[11px] font-medium leading-relaxed text-center text-foreground/60 dark:text-neutral-300">{tInstallPwaModal("ChromeFallback")}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SideMenu;
|
||||
export default SideMenu;
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import Link, { LinkProps } from 'next/link'
|
||||
import React from 'react'
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactElement
|
||||
title: string
|
||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||
icon: React.ReactElement;
|
||||
title: string;
|
||||
badge?: string;
|
||||
} & LinkProps &
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
export default function SideMenuItem({ href, title, icon, className, children, ...restProps }: Props) {
|
||||
return (
|
||||
<Link
|
||||
{...restProps}
|
||||
href={href}
|
||||
className={`
|
||||
export default function SideMenuItem({ href, title, icon, badge, className, children, ...restProps }: Props) {
|
||||
return (
|
||||
<Link
|
||||
{...restProps}
|
||||
href={href}
|
||||
className={`
|
||||
inline-flex gap-3 ps-6 xl:ps-9 h-full w-full items-center overflow-hidden text-nowrap
|
||||
text-xs font-light text-disabled-text border-transparent
|
||||
data-active:border-s-6 data-active:border-primary data-active:text-primary
|
||||
dark:data-active:border-foreground dark:data-active:text-foreground
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className='mt-0.5'>{title}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="flex items-start gap-2">
|
||||
<span className="mt-0.5">{title}</span>
|
||||
|
||||
{badge && <span className="rounded-md mt-0.5 border border-primary text-primary px-1.5 py-0.5 text-[8px] leading-none font-light dark:bg-white/15 dark:text-neutral-200">{badge}</span>}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,79 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import BottomNavLink from './BottomNavLink'
|
||||
import BottomNavHighlightLink from './BottomNavLinkBig'
|
||||
import PagerIcon from '../icons/PagerIcon'
|
||||
import BagIcon from '../icons/BagIcon'
|
||||
import HeartIcon from '../icons/HeartIcon'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import HomeIcon from '../icons/HomeIcon'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCart } from '@/app/[name]/(Main)/cart/hook/useCart';
|
||||
import { Building } from 'iconsax-react';
|
||||
import clsx from 'clsx';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { toastLoginRequired } from '../Toast';
|
||||
import { useGetCartItems } from "@/app/[name]/(Main)/cart/hooks/useCartData";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||
import { useReceiptStore } from "@/zustand/receiptStore";
|
||||
import clsx from "clsx";
|
||||
import type { Icon } from "iconsax-react";
|
||||
import { Bag2, Heart, Home2, InfoCircle, Reserve } from "iconsax-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { toastLoginRequired } from "../Toast";
|
||||
|
||||
type BottomNavBarProps = {
|
||||
onPagerClick?: () => void;
|
||||
};
|
||||
|
||||
type NavItemProps = {
|
||||
href?: string;
|
||||
isActive: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
onButtonClick?: () => void;
|
||||
Icon: Icon;
|
||||
badge?: number;
|
||||
};
|
||||
|
||||
function NavItem({ href, isActive, onClick, onButtonClick, Icon, badge }: NavItemProps) {
|
||||
const className = clsx("flex-1 flex items-center justify-center h-full rounded-full", isActive && "bg-primary/12");
|
||||
|
||||
const icon = (
|
||||
<span className="relative">
|
||||
<Icon size={20} color="currentColor" variant={isActive ? "Bold" : "Linear"} className={isActive ? "text-primary" : "text-icon-deactive"} />
|
||||
{badge != null && badge > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1 flex min-w-3.5 h-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-medium text-container dark:bg-white dark:text-black">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (onButtonClick) {
|
||||
return (
|
||||
<button type="button" onClick={onButtonClick} className={className}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href ?? "#"} onClick={onClick} className={className}>
|
||||
{icon}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
|
||||
|
||||
const params = useParams();
|
||||
const { name } = params;
|
||||
const name = params.name as string;
|
||||
const pathname = usePathname();
|
||||
const isHomeRoute = pathname === `/${name}`;
|
||||
const isAboutRoute = pathname.includes('about');
|
||||
const buildingVariant = React.useMemo(() => {
|
||||
return (isAboutRoute ? 'Bold' : 'Outline') as 'Bold' | 'Outline';
|
||||
}, [isAboutRoute]);
|
||||
const { isSuccess } = useGetProfile()
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation('common', {
|
||||
keyPrefix: 'BottomNavbar'
|
||||
});
|
||||
const { items } = useCart();
|
||||
const router = useRouter();
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const localItems = useReceiptStore((state) => state.items);
|
||||
|
||||
const cartItemsCount = React.useMemo(() => {
|
||||
return Object.values(items).reduce((total, item) => {
|
||||
return total + (item?.quantity || 0);
|
||||
}, 0);
|
||||
}, [items]);
|
||||
const guestItemCount = useMemo(
|
||||
() => Object.values(localItems).reduce((sum, item) => sum + (item?.quantity ?? 0), 0),
|
||||
[localItems],
|
||||
);
|
||||
const cartItemCount = isSuccess ? (cartData?.data?.totalItems ?? 0) : guestItemCount;
|
||||
|
||||
const isHomeActive = pathname === `/${name}`;
|
||||
const isFavoriteActive = pathname.startsWith(`/${name}/favorite`);
|
||||
const isPagerActive = pathname.includes("/pager");
|
||||
const isAboutActive = pathname.startsWith(`/${name}/about`);
|
||||
const isCartActive = pathname.startsWith(`/${name}/cart`);
|
||||
|
||||
const handleFavoritesClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -49,150 +81,19 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
|
||||
router.push(`/${name}/favorite`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
toastLoginRequired(() => router.push(`/${name}/auth`), 'error');
|
||||
toastLoginRequired(() => router.push(`/${name}/auth`), "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 w-full bg-transparent pointer-events-none">
|
||||
<div className="max-w-md mx-auto w-full aspect-436/104 relative z-30">
|
||||
<svg
|
||||
viewBox="0 0 436 104"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-full block"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g filter="url(#filter0_d_9095_18036)" className='fill-container'>
|
||||
<path
|
||||
className='pointer-events-auto'
|
||||
d="M218 16C229.034 16 238.711 22.0313 244.148 31.0943C245.844 33.9222 248.724 36 252.022 36H406C414.837 36 422 43.1634 422 52V80C422 88.8366 414.837 96 406 96H30C21.1634 96 14 88.8366 14 80V52C14 43.1634 21.1634 36 30 36H183.979C187.277 36 190.157 33.9222 191.853 31.0943C197.29 22.0315 206.966 16.0001 218 16Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* 🔽 Your HTML content inside SVG */}
|
||||
<foreignObject x="0" y="10" width="100%" height="80">
|
||||
<nav
|
||||
className="h-full px-4 grid grid-cols-5 gap-x-1 text-[10px] items-end"
|
||||
>
|
||||
<BottomNavLink
|
||||
href={`/${name}/cart`}
|
||||
icon={<BagIcon width={20} height={20} />}
|
||||
value={t('Cart')}
|
||||
/>
|
||||
{onPagerClick ? (
|
||||
<button
|
||||
onClick={onPagerClick}
|
||||
className="flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary dark:text-white dark:**:stroke-white"
|
||||
>
|
||||
<PagerIcon width={20} height={20} />
|
||||
<span className="text-xs2 dark:text-white">
|
||||
{t('Pager')}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<BottomNavLink href={`/${name}/pager`} icon={<PagerIcon width={20} height={20} />} value={t('Pager')} />
|
||||
)}
|
||||
<BottomNavHighlightLink
|
||||
href={`/${name}`}
|
||||
icon={
|
||||
<HomeIcon
|
||||
className={clsx(
|
||||
'transition-all duration-200 stroke-primary dark:stroke-black! dark:text-white'
|
||||
)}
|
||||
fill="white"
|
||||
stroke="currentColor"
|
||||
variant={isHomeRoute ? 'Bold' : 'Outline'}
|
||||
width={20}
|
||||
height={20} />}
|
||||
value={t('Menu')} />
|
||||
<Link
|
||||
href={`/${name}/about`}
|
||||
className={clsx(
|
||||
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white'
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 text-primary dark:text-white">
|
||||
<Building
|
||||
key={`building-${buildingVariant}-${isAboutRoute}`}
|
||||
size={20}
|
||||
variant={buildingVariant as 'Bold' | 'Outline'}
|
||||
color="currentColor"
|
||||
className='stroke-0'
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs2 text-center truncate w-full dark:text-white">
|
||||
{t('about')}
|
||||
</span>
|
||||
</Link>
|
||||
<BottomNavLink href={`/${name}/favorite`} onClick={handleFavoritesClick} icon={<HeartIcon width={20} height={20} />} value={t('Favorites')} />
|
||||
</nav>
|
||||
</foreignObject>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_9095_18036"
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="-2" />
|
||||
<feGaussianBlur stdDeviation="7" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_9095_18036"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_9095_18036"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Badge Layer - خارج از SVG - دقیقاً مثل foreignObject */}
|
||||
{cartItemsCount > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
top: 'calc(10 / 104 * 100%)',
|
||||
height: 'calc(80 / 104 * 100%)'
|
||||
}}
|
||||
>
|
||||
<div className="h-full px-4 grid grid-cols-5 gap-x-1 items-end">
|
||||
<div className="flex flex-col items-center gap-[5px]">
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-5 h-5 opacity-0"></div>
|
||||
<span className="absolute sm:-top-1 sm:-right-1 top-0 -right-1 flex items-center justify-center min-w-[14px] min-h-[14px] px-1 text-[8px] font-medium text-white dark:bg-white dark:text-black bg-primary rounded-full z-50">
|
||||
{cartItemsCount > 99 ? '99+' : cartItemsCount}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs2 opacity-0">{t('Cart')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={glassSurfaceNav("mx-auto w-full rounded-full flex items-center h-14 max-w-md gap-2.5")}>
|
||||
<NavItem href={`/${name}`} isActive={isHomeActive} Icon={Home2} />
|
||||
<NavItem href={`/${name}/favorite`} isActive={isFavoriteActive} onClick={handleFavoritesClick} Icon={Heart} />
|
||||
{onPagerClick ? <NavItem isActive={isPagerActive} onButtonClick={onPagerClick} Icon={Reserve} /> : <NavItem href={`/${name}/pager`} isActive={isPagerActive} Icon={Reserve} />}
|
||||
<NavItem href={`/${name}/about`} isActive={isAboutActive} Icon={InfoCircle} />
|
||||
<NavItem href={`/${name}/cart`} isActive={isCartActive} Icon={Bag2} badge={cartItemCount} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomNavBar
|
||||
export default BottomNavBar;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
@@ -12,22 +11,17 @@ type Props = {
|
||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||
|
||||
function BottomNavLink({ icon, value, href, prefetch, ...restProps }: Props) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href;
|
||||
const shouldPrefetch = prefetch ?? true;
|
||||
|
||||
return (
|
||||
<Link {...restProps} href={href} prefetch={shouldPrefetch} className={clsx(
|
||||
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white',
|
||||
isActive && 'text-primary-foreground **:stroke-primary-foreground',
|
||||
'flex flex-col justify-arround items-center gap-[5px] pointer-events-auto min-w-0 text-primary **:stroke-primary',
|
||||
restProps.className ?? '',
|
||||
)}>
|
||||
<div className="shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs2 text-center truncate w-full dark:text-white',
|
||||
)}>
|
||||
<span className="text-xs2 text-center truncate w-full">
|
||||
{value}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
@@ -12,9 +11,6 @@ type Props = {
|
||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||
|
||||
function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href;
|
||||
|
||||
return (
|
||||
<Link {...restProps} href={href} className={clsx(
|
||||
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto',
|
||||
@@ -25,7 +21,7 @@ function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-xs2 dark:text-white">
|
||||
<span className="text-xs2 text-primary">
|
||||
{value}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export const BOTTOM_NAV_SHAPE_PATH =
|
||||
"M218 16C229.034 16 238.711 22.0313 244.148 31.0943C245.844 33.9222 248.724 36 252.022 36H406C414.837 36 422 43.1634 422 52V80C422 88.8366 414.837 96 406 96H30C21.1634 96 14 88.8366 14 80V52C14 43.1634 21.1634 36 30 36H183.979C187.277 36 190.157 33.9222 191.853 31.0943C197.29 22.0315 206.966 16.0001 218 16Z";
|
||||
|
||||
export const BOTTOM_NAV_VIEWBOX = "0 0 436 104";
|
||||
|
||||
export function getBottomNavMaskStyle(): CSSProperties {
|
||||
const maskSvg = `<svg viewBox="${BOTTOM_NAV_VIEWBOX}" xmlns="http://www.w3.org/2000/svg"><path fill="white" d="${BOTTOM_NAV_SHAPE_PATH}"/></svg>`;
|
||||
const maskUrl = `url("data:image/svg+xml,${encodeURIComponent(maskSvg)}")`;
|
||||
|
||||
return {
|
||||
WebkitMaskImage: maskUrl,
|
||||
maskImage: maskUrl,
|
||||
WebkitMaskSize: "100% 100%",
|
||||
maskSize: "100% 100%",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useLayoutEffect, useState } from 'react'
|
||||
import PingAnimation from './animations/PingAnimation';
|
||||
|
||||
type Props = {
|
||||
@@ -10,21 +10,23 @@ type Props = {
|
||||
} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
|
||||
const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children = <PingAnimation /> }: Props) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loaded, setLoaded] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger fade-in after mount
|
||||
const timeout = setTimeout(() => setLoaded(visible), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
useLayoutEffect(() => {
|
||||
setLoaded(visible);
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-visible={visible}
|
||||
data-loaded={loaded}
|
||||
className={`absolute inset-0 flex items-center justify-center
|
||||
backdrop-blur-sm bg-background/${bgOpacity}
|
||||
opacity-0 transition-opacity duration-300 ease-in-out delay-${delay}
|
||||
transition-opacity duration-300 ease-in-out delay-${delay}
|
||||
data-[loaded=false]:-z-50
|
||||
data-[visible=true]:z-50
|
||||
data-[visible=true]:opacity-100`}
|
||||
@@ -34,4 +36,4 @@ const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children =
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingOverlay
|
||||
export default LoadingOverlay
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const MAX_DISPLAY_MS = 1200;
|
||||
const EXIT_MS = 350;
|
||||
@@ -14,27 +14,21 @@ interface SplashScreenProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function SplashScreen({
|
||||
logo,
|
||||
restaurantName,
|
||||
onDismiss,
|
||||
}: SplashScreenProps) {
|
||||
export default function SplashScreen({ logo, restaurantName, onDismiss }: SplashScreenProps) {
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
const [logoState, setLogoState] = useState<'idle' | 'loading' | 'ready' | 'error'>(
|
||||
logo ? 'loading' : 'idle',
|
||||
);
|
||||
const [logoState, setLogoState] = useState<"idle" | "loading" | "ready" | "error">(logo ? "loading" : "idle");
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
onDismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
setLogoState(logo ? 'loading' : 'idle');
|
||||
setLogoState(logo ? "loading" : "idle");
|
||||
}, [logo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logo || logoState !== 'loading') return;
|
||||
if (!logo || logoState !== "loading") return;
|
||||
|
||||
const logoTimeout = setTimeout(() => {
|
||||
setLogoState('error');
|
||||
setLogoState("error");
|
||||
}, LOGO_TIMEOUT_MS);
|
||||
|
||||
return () => clearTimeout(logoTimeout);
|
||||
@@ -55,14 +49,13 @@ export default function SplashScreen({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showLogoSpinner = Boolean(logo) && logoState === 'loading';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity }}
|
||||
transition={{ duration: EXIT_MS / 1000, ease: 'easeOut' }}
|
||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background"
|
||||
transition={{ duration: EXIT_MS / 1000, ease: "easeOut" }}
|
||||
className="fixed inset-0 z-9999 flex flex-col items-center justify-center bg-background"
|
||||
data-slot="splash-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
@@ -74,7 +67,7 @@ export default function SplashScreen({
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
||||
/>
|
||||
@@ -87,35 +80,30 @@ export default function SplashScreen({
|
||||
duration: 3,
|
||||
delay: 0.5,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute -bottom-20 -left-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-8 px-8 w-full max-w-md relative z-10">
|
||||
{logo && logoState !== 'error' && (
|
||||
{logo && logoState !== "error" && (
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={restaurantName || 'Restaurant Logo'}
|
||||
alt={restaurantName || "Restaurant Logo"}
|
||||
width={200}
|
||||
height={100}
|
||||
unoptimized
|
||||
priority
|
||||
className="object-contain"
|
||||
onLoad={() => setLogoState('ready')}
|
||||
onError={() => setLogoState('error')}
|
||||
onLoad={() => setLogoState("ready")}
|
||||
onError={() => setLogoState("error")}
|
||||
style={{
|
||||
opacity: logoState === 'ready' ? 1 : 0,
|
||||
transition: 'opacity 0.3s',
|
||||
opacity: logoState === "ready" ? 1 : 0,
|
||||
transition: "opacity 0.3s",
|
||||
}}
|
||||
/>
|
||||
{showLogoSpinner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -131,7 +119,7 @@ export default function SplashScreen({
|
||||
duration: 1.5,
|
||||
delay,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
||||
/>
|
||||
|
||||
@@ -1,138 +1,111 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { ReactElement, ReactNode, useCallback, useState } from 'react'
|
||||
import HorizontalScrollView from '../listview/HorizontalScrollView'
|
||||
import { TabHeader, TabItemProps } from './TabHeader';
|
||||
import clsx from 'clsx';
|
||||
import TabHeaderRenderer from './TabHeaderRenderer';
|
||||
import Image from 'next/image';
|
||||
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import React, { ReactElement, ReactNode, useCallback, useState } from "react";
|
||||
import HorizontalScrollView from "../listview/HorizontalScrollView";
|
||||
import { TabHeader, TabItemProps } from "./TabHeader";
|
||||
import TabHeaderRenderer from "./TabHeaderRenderer";
|
||||
|
||||
type Props = {
|
||||
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
|
||||
children: ReactNode;
|
||||
className?: TabContainerClassName;
|
||||
changeType?: TabContainerRenderType;
|
||||
defaultIndex?: number;
|
||||
selectedTab?: number;
|
||||
onTabChange?: (index: number) => void;
|
||||
} & Omit<React.OlHTMLAttributes<HTMLUListElement>, 'className'>;
|
||||
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
|
||||
children: ReactNode;
|
||||
className?: TabContainerClassName;
|
||||
changeType?: TabContainerRenderType;
|
||||
defaultIndex?: number;
|
||||
selectedTab?: number;
|
||||
onTabChange?: (index: number) => void;
|
||||
} & Omit<React.OlHTMLAttributes<HTMLUListElement>, "className">;
|
||||
|
||||
export enum TabContainerRenderType {
|
||||
RENDERER,
|
||||
VISIBILITY,
|
||||
RENDERER,
|
||||
VISIBILITY,
|
||||
}
|
||||
|
||||
export type TabContainerClassName = {
|
||||
wrapper?: string;
|
||||
scrollView?: string;
|
||||
header?: string;
|
||||
headerDeactive?: string;
|
||||
headerActive?: string;
|
||||
icon?: string;
|
||||
iconDeactive?: string;
|
||||
iconActive?: string;
|
||||
title?: string;
|
||||
titleDeactive?: string;
|
||||
titleActive?: string
|
||||
}
|
||||
wrapper?: string;
|
||||
scrollView?: string;
|
||||
header?: string;
|
||||
headerDeactive?: string;
|
||||
headerActive?: string;
|
||||
icon?: string;
|
||||
iconDeactive?: string;
|
||||
iconActive?: string;
|
||||
title?: string;
|
||||
titleDeactive?: string;
|
||||
titleActive?: string;
|
||||
};
|
||||
|
||||
export const TabContainerClassNames: TabContainerClassName = {
|
||||
wrapper: 'h-full',
|
||||
scrollView: 'h-[72px] gradient-border !pt-3 !pb-2 w-full overflow-y-hidden bg-[#FFFFFF70] dark:bg-container/70 justify-center rounded-[32px] inline-flex items-center gap-10',
|
||||
header: '',
|
||||
headerDeactive: '',
|
||||
headerActive: '',
|
||||
icon: 'text-xs transition-all duration-150',
|
||||
iconDeactive: 'stroke-disabled-text',
|
||||
iconActive: 'stroke-primary dark:stroke-foreground',
|
||||
title: 'text-xs transition-all duration-150',
|
||||
titleDeactive: 'text-disabled-text',
|
||||
titleActive: 'text-primary dark:text-foreground'
|
||||
wrapper: "h-full",
|
||||
scrollView: glassSurfaceNav("h-[72px] gradient-border !pt-3 !pb-2 w-full overflow-y-hidden justify-center rounded-[32px] inline-flex items-center gap-10 !shadow-none"),
|
||||
header: "",
|
||||
headerDeactive: "",
|
||||
headerActive: "",
|
||||
icon: "text-xs transition-all duration-150",
|
||||
iconDeactive: "stroke-disabled-text",
|
||||
iconActive: "stroke-primary dark:stroke-foreground",
|
||||
title: "text-xs transition-all duration-150",
|
||||
titleDeactive: "text-disabled-text",
|
||||
titleActive: "text-primary dark:text-foreground",
|
||||
};
|
||||
|
||||
function TabContainer({
|
||||
className = TabContainerClassNames,
|
||||
changeType = TabContainerRenderType.RENDERER,
|
||||
defaultIndex = 0,
|
||||
selectedTab: controlledSelectedTab,
|
||||
onTabChange,
|
||||
itemRenderer,
|
||||
children,
|
||||
...restProps
|
||||
}: Props) {
|
||||
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultIndex);
|
||||
const selectedTab = controlledSelectedTab !== undefined ? controlledSelectedTab : internalSelectedTab;
|
||||
|
||||
const updateTab = useCallback(
|
||||
(index: number) => {
|
||||
if (onTabChange) {
|
||||
onTabChange(index);
|
||||
} else {
|
||||
setInternalSelectedTab(index);
|
||||
}
|
||||
},
|
||||
[onTabChange],
|
||||
);
|
||||
|
||||
const items = React.Children.toArray(children).filter((child): child is ReactElement<TabItemProps> => React.isValidElement(child) && child.type === TabHeader);
|
||||
|
||||
return (
|
||||
<div className={className.wrapper}>
|
||||
<HorizontalScrollView className={className.scrollView} itemRenderer={itemRenderer} {...restProps}>
|
||||
{items.map((item, index) => {
|
||||
const { title, imageSrc, icon }: TabItemProps = item.props;
|
||||
return (
|
||||
<TabHeaderRenderer key={index} className={clsx(className.header, index === selectedTab ? className.headerActive : className.headerDeactive)} onClick={() => updateTab(index)}>
|
||||
{imageSrc && <Image priority src={imageSrc} width={24} height={24} alt="category image" />}
|
||||
{icon && <span className={clsx(className.icon, index === selectedTab ? className.iconActive : className.iconDeactive)}>{icon}</span>}
|
||||
<h6 className={clsx(className.title, index === selectedTab ? className.titleActive : className.titleDeactive)}>{title}</h6>
|
||||
</TabHeaderRenderer>
|
||||
);
|
||||
})}
|
||||
</HorizontalScrollView>
|
||||
|
||||
{changeType === TabContainerRenderType.RENDERER && <div>{items[selectedTab]?.props?.viewRenderer ?? <div></div>}</div>}
|
||||
{changeType === TabContainerRenderType.VISIBILITY && (
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className={clsx(index === selectedTab ? "visible" : "hidden")}>
|
||||
{item.props.viewRenderer}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContainer({ className = TabContainerClassNames, changeType = TabContainerRenderType.RENDERER, defaultIndex = 0, selectedTab: controlledSelectedTab, onTabChange, itemRenderer, children, ...restProps }: Props) {
|
||||
|
||||
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultIndex);
|
||||
const selectedTab = controlledSelectedTab !== undefined ? controlledSelectedTab : internalSelectedTab;
|
||||
|
||||
const updateTab = useCallback((index: number) => {
|
||||
if (onTabChange) {
|
||||
onTabChange(index);
|
||||
} else {
|
||||
setInternalSelectedTab(index);
|
||||
}
|
||||
}, [onTabChange]);
|
||||
|
||||
const items = React.Children.toArray(children).filter(
|
||||
(child): child is ReactElement<TabItemProps> =>
|
||||
React.isValidElement(child) && child.type === TabHeader
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className.wrapper}>
|
||||
<HorizontalScrollView
|
||||
className={className.scrollView}
|
||||
itemRenderer={itemRenderer}
|
||||
{...restProps}>
|
||||
{items.map((item, index) => {
|
||||
const { title, imageSrc, icon }: TabItemProps = item.props;
|
||||
return (
|
||||
<TabHeaderRenderer
|
||||
key={index}
|
||||
className={clsx(
|
||||
className.header,
|
||||
index === selectedTab ? className.headerActive : className.headerDeactive
|
||||
)}
|
||||
onClick={() => updateTab(index)}
|
||||
>
|
||||
{imageSrc &&
|
||||
<Image
|
||||
priority
|
||||
src={imageSrc}
|
||||
width={24}
|
||||
height={24}
|
||||
alt="category image"
|
||||
/>
|
||||
}
|
||||
{icon &&
|
||||
<span className={clsx(
|
||||
className.icon,
|
||||
index === selectedTab ? className.iconActive : className.iconDeactive
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
}
|
||||
<h6 className={clsx(
|
||||
className.title,
|
||||
index === selectedTab ? className.titleActive : className.titleDeactive
|
||||
)}>
|
||||
{title}
|
||||
</h6>
|
||||
</TabHeaderRenderer>
|
||||
)
|
||||
})}
|
||||
</HorizontalScrollView>
|
||||
|
||||
{changeType === TabContainerRenderType.RENDERER &&
|
||||
<div>
|
||||
{items[selectedTab]?.props?.viewRenderer ?? <div></div>}
|
||||
</div>
|
||||
}
|
||||
{changeType === TabContainerRenderType.VISIBILITY &&
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className={clsx(
|
||||
index === selectedTab ? 'visible' : 'hidden'
|
||||
)}>
|
||||
{item.props.viewRenderer}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabContainer
|
||||
export default TabContainer;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user