Compare commits
67 Commits
461112c8ab
...
production
| 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 |
+3
-3
@@ -3,11 +3,11 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
# Change Alpine repo
|
# 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)
|
# Configure npm registry mirror (Liara)
|
||||||
# ENV NPM_CONFIG_REGISTRY=https://mirror-npm.runflare.com
|
ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||||
# RUN npm config set registry https://mirror-npm.runflare.com
|
RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
|
||||||
|
|
||||||
# Install tzdata to support timezone settings
|
# Install tzdata to support timezone settings
|
||||||
# RUN apk add --no-cache tzdata && \
|
# 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 { useRouter } from 'next/navigation'
|
||||||
import Seperator from '@/components/utils/Seperator'
|
import Seperator from '@/components/utils/Seperator'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface'
|
||||||
|
|
||||||
const GamePage: FC = () => {
|
const GamePage: FC = () => {
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ const GamePage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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='max-w-[99px]'>
|
||||||
<div className='font-bold'>My Games</div>
|
<div className='font-bold'>My Games</div>
|
||||||
<div className='mt-1'>
|
<div className='mt-1'>
|
||||||
@@ -46,7 +47,7 @@ const GamePage: FC = () => {
|
|||||||
<div className='mt-8 flex flex-col gap-4'>
|
<div className='mt-8 flex flex-col gap-4'>
|
||||||
<div
|
<div
|
||||||
style={{ direction: 'ltr' }}
|
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={() => {
|
onClick={() => {
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const basePath = currentPath.split('/game')[0]
|
const basePath = currentPath.split('/game')[0]
|
||||||
@@ -76,7 +77,7 @@ const GamePage: FC = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{ direction: 'ltr' }}
|
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={() => {
|
onClick={() => {
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const basePath = currentPath.split('/game')[0]
|
const basePath = currentPath.split('/game')[0]
|
||||||
@@ -106,7 +107,7 @@ const GamePage: FC = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{ direction: 'ltr' }}
|
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={() => {
|
onClick={() => {
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
const basePath = currentPath.split('/game')[0]
|
const basePath = currentPath.split('/game')[0]
|
||||||
|
|||||||
@@ -2,21 +2,11 @@
|
|||||||
|
|
||||||
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
|
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
function DialogsLayout({ children }: { children: React.ReactNode }) {
|
function DialogsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isCheckoutPage = pathname?.includes('/checkout/');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientSideWrapper>
|
<ClientSideWrapper>
|
||||||
<main className={clsx(
|
<main className='h-full bg-background dark:bg-background w-full overflow-y-auto noscrollbar pt-4 pb-6 px-6'>
|
||||||
'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'
|
|
||||||
)}>
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</ClientSideWrapper>
|
</ClientSideWrapper>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useGetNotifications, useReadAllNotifications } from './hooks/useNotific
|
|||||||
import type { Notification } from './types/Types';
|
import type { Notification } from './types/Types';
|
||||||
import { useGetNotificationsCount } from '../../(Main)/hooks/useMenuData';
|
import { useGetNotificationsCount } from '../../(Main)/hooks/useMenuData';
|
||||||
import Prompt from '@/components/utils/Prompt';
|
import Prompt from '@/components/utils/Prompt';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export default function NotificationsIndex({ }: Props) {
|
|||||||
const formattedTime = formatTime(notification.createdAt);
|
const formattedTime = formatTime(notification.createdAt);
|
||||||
|
|
||||||
return (
|
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> */}
|
{/* <h5 className='font-medium text-sm'>{notification.title}</h5> */}
|
||||||
<p className='text-xs mt-2 leading-5'>{notification.content}</p>
|
<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 { useParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
import { useCheckoutStore } from '../../store/Store';
|
import { useCheckoutStore } from '../../store/Store';
|
||||||
import useToggle from '@/hooks/helpers/useToggle';
|
import useToggle from '@/hooks/helpers/useToggle';
|
||||||
import { AddressSelectionModal } from './AddressSelectionModal';
|
import { AddressSelectionModal } from './AddressSelectionModal';
|
||||||
|
|
||||||
const formatAddress = (address: Address) => {
|
const formatAddress = (address: Address) => {
|
||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return address.address;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddressSection = () => {
|
export const AddressSection = () => {
|
||||||
@@ -66,7 +67,7 @@ export const AddressSection = () => {
|
|||||||
// اگر آدرس ندارد
|
// اگر آدرس ندارد
|
||||||
if (addresses.length === 0) {
|
if (addresses.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<h3 className="text-sm2 font-medium leading-5 mb-4">{t("SectionAddress.Title")}</h3>
|
<h3 className="text-sm2 font-medium leading-5 mb-4">{t("SectionAddress.Title")}</h3>
|
||||||
<p className='text-sm2 text-disabled-text mb-4'>
|
<p className='text-sm2 text-disabled-text mb-4'>
|
||||||
@@ -89,7 +90,7 @@ export const AddressSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<div className='w-full flex justify-between items-center gap-2'>
|
<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>
|
<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';
|
import { Edit2 } from 'iconsax-react';
|
||||||
|
|
||||||
const formatAddress = (address: Address) => {
|
const formatAddress = (address: Address) => {
|
||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return address.address;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AddressSelectionModalProps {
|
interface AddressSelectionModalProps {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import InputField from '@/components/input/InputField';
|
import InputField from '@/components/input/InputField';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
import { useCheckoutStore } from '../../store/Store';
|
import { useCheckoutStore } from '../../store/Store';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export const CarAddressSection = () => {
|
|||||||
}, [carAddress, setCarAddress]);
|
}, [carAddress, setCarAddress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<h3 className="text-sm2 font-medium leading-5 mb-4">
|
<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 Button from '@/components/button/PrimaryButton';
|
||||||
import { extractErrorMessage } from '@/lib/func';
|
import { extractErrorMessage } from '@/lib/func';
|
||||||
import { toast } from '@/components/Toast';
|
import { toast } from '@/components/Toast';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
|
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export const CouponSection = ({
|
|||||||
}, [cartItems, onCouponCodeChange]);
|
}, [cartItems, onCouponCodeChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<h3 className="text-sm2 font-medium leading-5">{t("SectionCoupon.Title")}</h3>
|
<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 { extractErrorMessage } from "@/lib/func";
|
||||||
import { Card, Icon, Wallet2 } from "iconsax-react";
|
import { Card, Icon, Wallet2 } from "iconsax-react";
|
||||||
import Image from "next/image";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { PaymentMethodEnum } from "../../enum/Enum";
|
import { PaymentMethodEnum } from "../../enum/Enum";
|
||||||
import { useGetPaymentMethod } from "../../hooks/useOrderData";
|
import { useGetPaymentMethod } from "../../hooks/useOrderData";
|
||||||
@@ -19,6 +20,11 @@ type PaymentSectionProps = {
|
|||||||
onPaymentTypeChange: (id: string) => void;
|
onPaymentTypeChange: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreditCardOption = {
|
||||||
|
cardNumber: string;
|
||||||
|
cardOwner: string;
|
||||||
|
};
|
||||||
|
|
||||||
const getIconByMethod = (method: PaymentMethodEnum): Icon => {
|
const getIconByMethod = (method: PaymentMethodEnum): Icon => {
|
||||||
if (method === PaymentMethodEnum.Online || method === PaymentMethodEnum.CreditCard) {
|
if (method === PaymentMethodEnum.Online || method === PaymentMethodEnum.CreditCard) {
|
||||||
return Card;
|
return Card;
|
||||||
@@ -39,6 +45,7 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
|||||||
const { data: cartData } = useGetCartItems(isSuccess);
|
const { data: cartData } = useGetCartItems(isSuccess);
|
||||||
const { mutateAsync: singleUpload, isPending: isUploading } = useSingleUpload();
|
const { mutateAsync: singleUpload, isPending: isUploading } = useSingleUpload();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [selectedCardNumber, setSelectedCardNumber] = useState<string>("");
|
||||||
|
|
||||||
const walletAmount = userWallet?.data?.balance ?? 0;
|
const walletAmount = userWallet?.data?.balance ?? 0;
|
||||||
const totalAmount = cartData?.data?.total ?? 0;
|
const totalAmount = cartData?.data?.total ?? 0;
|
||||||
@@ -68,6 +75,11 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
|||||||
method: item.method,
|
method: item.method,
|
||||||
cardNumber: item.cardNumber,
|
cardNumber: item.cardNumber,
|
||||||
cardOwner: item.cardOwner,
|
cardOwner: item.cardOwner,
|
||||||
|
cards:
|
||||||
|
item.cards?.map((card) => ({
|
||||||
|
cardNumber: card.cardNumber,
|
||||||
|
cardOwner: card.owner,
|
||||||
|
})) ?? [],
|
||||||
description: item.description,
|
description: item.description,
|
||||||
isWallet: item.method === PaymentMethodEnum.Wallet,
|
isWallet: item.method === PaymentMethodEnum.Wallet,
|
||||||
}));
|
}));
|
||||||
@@ -84,11 +96,45 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
|||||||
if (selected?.method !== PaymentMethodEnum.CreditCard) {
|
if (selected?.method !== PaymentMethodEnum.CreditCard) {
|
||||||
setPaymentDesc("");
|
setPaymentDesc("");
|
||||||
setPaymentAttachments([]);
|
setPaymentAttachments([]);
|
||||||
|
setSelectedCardNumber("");
|
||||||
}
|
}
|
||||||
onPaymentTypeChange(value);
|
onPaymentTypeChange(value);
|
||||||
setIsSelectedPayment(true);
|
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(
|
const handleCopyCardNumber = useCallback(
|
||||||
async (cardNumber: string) => {
|
async (cardNumber: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -121,7 +167,7 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionPayment.Title")}</h3>
|
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionPayment.Title")}</h3>
|
||||||
|
|
||||||
@@ -146,24 +192,49 @@ export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSect
|
|||||||
|
|
||||||
{selectedCreditCard && (
|
{selectedCreditCard && (
|
||||||
<div className="mt-6 pt-4 border-t border-border flex flex-col gap-4">
|
<div className="mt-6 pt-4 border-t border-border flex flex-col gap-4">
|
||||||
{(selectedCreditCard.cardOwner || selectedCreditCard.cardNumber) && (
|
{creditCardOptions.length > 0 && (
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="space-y-3">
|
||||||
{selectedCreditCard.cardOwner && (
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div>
|
{`تعداد کارت های مقصد: ${creditCardOptions.length}`}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{tOrderDetail("SectionPayment.CreditCard.CardOwnerLabel")}</span>
|
</p>
|
||||||
<p className="text-sm font-medium mt-1">{selectedCreditCard.cardOwner}</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedCreditCard.cardNumber && (
|
{selectedCard && (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{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">{selectedCard.cardOwner}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCard.cardNumber && (
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{tOrderDetail("SectionPayment.CreditCard.CardNumberLabel")}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">{tOrderDetail("SectionPayment.CreditCard.CardNumberLabel")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block text-sm font-medium mt-1 cursor-pointer hover:text-primary transition-colors"
|
className="block text-sm font-medium mt-1 cursor-pointer hover:text-primary transition-colors"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
onClick={() => handleCopyCardNumber(selectedCreditCard.cardNumber!)}
|
onClick={() => handleCopyCardNumber(selectedCard.cardNumber)}
|
||||||
>
|
>
|
||||||
{selectedCreditCard.cardNumber}
|
{selectedCard.cardNumber}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useGetShipmentMethod } from '../../hooks/useOrderData';
|
import { useGetShipmentMethod } from '../../hooks/useOrderData';
|
||||||
import { DeliveryMethod } from '../../types/Types';
|
import { DeliveryMethod } from '../../types/Types';
|
||||||
import { useCheckoutStore } from '../../store/Store';
|
import { useCheckoutStore } from '../../store/Store';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type ShippingSectionProps = {
|
type ShippingSectionProps = {
|
||||||
deliveryType: string;
|
deliveryType: string;
|
||||||
@@ -57,7 +58,7 @@ export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: Shipping
|
|||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
<section className={glassSurfaceCard('p-4')}>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionShipping.Title")}</h3>
|
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionShipping.Title")}</h3>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -80,4 +81,3 @@ export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: Shipping
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { MinusIcon, PlusIcon } from 'lucide-react';
|
import { MinusIcon, PlusIcon } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type TableSectionProps = {
|
type TableSectionProps = {
|
||||||
tableNumber: number;
|
tableNumber: number;
|
||||||
@@ -14,7 +15,7 @@ export const TableSection = ({ tableNumber, onIncrement, onDecrement }: TableSec
|
|||||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||||
|
|
||||||
return (
|
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="py-3">
|
||||||
<div className='w-full grid grid-cols-2 justify-between items-center gap-2'>
|
<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>
|
<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'>
|
<div className='w-full flex justify-end'>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={{ scale: 1.05 }}
|
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
|
<button
|
||||||
onClick={onIncrement}
|
onClick={onIncrement}
|
||||||
|
|||||||
@@ -116,10 +116,21 @@ export interface PaymentMethodResponse {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
merchantId: string | null;
|
merchantId: string | null;
|
||||||
|
cards?: PaymentMethodCard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentMethodsResponse = BaseResponse<PaymentMethodResponse[]>;
|
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 = {
|
export type CheckoutStoreType = {
|
||||||
isSelectedAddress: boolean;
|
isSelectedAddress: boolean;
|
||||||
setIsSelectedAddress: (value: boolean) => void;
|
setIsSelectedAddress: (value: boolean) => void;
|
||||||
@@ -196,8 +207,6 @@ export interface OrderAddress {
|
|||||||
user: OrderUser;
|
user: OrderUser;
|
||||||
title: string;
|
title: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
|
||||||
province: string;
|
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -439,12 +448,10 @@ export interface OrderDetailCarAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderDetailUserAddress {
|
export interface OrderDetailUserAddress {
|
||||||
city: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
province: string;
|
|
||||||
longitude: number;
|
longitude: number;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
}
|
}
|
||||||
@@ -468,6 +475,7 @@ export interface OrderDetail {
|
|||||||
subTotal: number;
|
subTotal: number;
|
||||||
tax: number;
|
tax: number;
|
||||||
deliveryFee: number;
|
deliveryFee: number;
|
||||||
|
prepareTime: number | null;
|
||||||
total: number;
|
total: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Clock } from "iconsax-react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { OrderStatus } from "../../checkout/enum/Enum";
|
||||||
import { useCancelOrder, useGetOrderDetail } from "../../checkout/hooks/useOrderData";
|
import { useCancelOrder, useGetOrderDetail } from "../../checkout/hooks/useOrderData";
|
||||||
|
|
||||||
const getDeliveryMethodTitle = (method: string) => {
|
const getDeliveryMethodTitle = (method: string) => {
|
||||||
@@ -132,6 +133,14 @@ function OrderTrackingPage() {
|
|||||||
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
|
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 text-muted-foreground">نوع ارسال</span>
|
||||||
<span className="text-sm font-semibold">{orderDetail.data?.deliveryMethod?.description}</span>
|
<span className="text-sm font-semibold">{orderDetail.data?.deliveryMethod?.description}</span>
|
||||||
@@ -215,7 +224,11 @@ function OrderTrackingPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
onClick={toggleCancelModal}
|
onClick={toggleCancelModal}
|
||||||
className="bg-disabled! text-foreground!"
|
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>
|
</Button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { useVerify } from '../hooks/useVerifyData';
|
import { useVerify } from '../hooks/useVerifyData';
|
||||||
import { toast } from '@/components/Toast';
|
import { toast } from '@/components/Toast';
|
||||||
import { VerifyData } from '../types/Types';
|
import { VerifyData } from '../types/Types';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export default function VerifyIndex({ }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className='h-full bg-inherit relative flex flex-col lg:gap-4'>
|
<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='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'}`}>
|
<div className={`items-center gap-2 rounded-2xl h-35 flex flex-col justify-center ${isError ? 'bg-red-50' : 'bg-gray-50'}`}>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<CloseCircle className='size-10 stroke-red-500' variant='TwoTone' />
|
<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 * as api from "../service/Service";
|
||||||
|
import { CreateReviewType } from "../types/ReviewTypes";
|
||||||
|
|
||||||
export const useGetFood = (id: string) => {
|
export const useGetFood = (id: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -13,3 +14,22 @@ export const useToggleFavorite = () => {
|
|||||||
mutationFn: api.toggleFavorite,
|
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 { toast, toastLoginRequired } from "@/components/Toast";
|
||||||
import { getToken } from "@/lib/api/func";
|
import { getToken } from "@/lib/api/func";
|
||||||
import { ef } from "@/lib/helpers/utfNumbers";
|
import { ef } from "@/lib/helpers/utfNumbers";
|
||||||
import clsx from "clsx";
|
import { glassSurface, glassSurfaceCard, glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react";
|
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
|
||||||
import { useGetAbout } from "../about/hooks/useAboutData";
|
import { useGetAbout } from "../about/hooks/useAboutData";
|
||||||
import { useGetFood, useToggleFavorite } from "./hooks/useFoodData";
|
import { useGetFood, useToggleFavorite } from "./hooks/useFoodData";
|
||||||
|
import FoodReviewsSection from "./components/FoodReviewsSection";
|
||||||
|
|
||||||
type Props = object;
|
type Props = object;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ function FoodPage({}: Props) {
|
|||||||
const { items, addToCart, removeFromCart } = useCart();
|
const { items, addToCart, removeFromCart } = useCart();
|
||||||
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
||||||
const { mutate: toggleFavorite } = useToggleFavorite();
|
const { mutate: toggleFavorite } = useToggleFavorite();
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
const foodId = food?.data?.id || (id as string);
|
const foodId = food?.data?.id || (id as string);
|
||||||
const quantity = useMemo(() => {
|
const quantity = useMemo(() => {
|
||||||
const item = items?.[foodId];
|
const item = items?.[foodId];
|
||||||
@@ -80,6 +81,39 @@ function FoodPage({}: Props) {
|
|||||||
}
|
}
|
||||||
}, [food?.data?.isFavorite]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<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
|
: Array.isArray(foodData.images) && foodData.images.length > 0
|
||||||
? typeof foodData.images[0] === "string"
|
? typeof foodData.images[0] === "string"
|
||||||
? foodData.images[0]
|
? foodData.images[0]
|
||||||
: typeof foodData.images[0] === "object" &&
|
: typeof foodData.images[0] === "object" && foodData.images[0] !== null && "url" in foodData.images[0]
|
||||||
foodData.images[0] !== null &&
|
|
||||||
"url" in foodData.images[0]
|
|
||||||
? (foodData.images[0] as { url: string }).url
|
? (foodData.images[0] as { url: string }).url
|
||||||
: "/assets/images/no-image.png"
|
: "/assets/images/no-image.png"
|
||||||
: "/assets/images/no-image.png";
|
: "/assets/images/no-image.png";
|
||||||
const categoryName = foodData.category?.title;
|
const categoryName = foodData.category?.title;
|
||||||
const prepareTime = foodData.prepareTime || 0;
|
const prepareTime = foodData.prepareTime || 0;
|
||||||
const price = foodData.price || 0;
|
const price = foodData.price || 0;
|
||||||
const content = Array.isArray(foodData.content)
|
const content = Array.isArray(foodData.content) ? foodData.content.join("، ") : foodData.content || foodData.desc || "";
|
||||||
? foodData.content.join("، ")
|
|
||||||
: foodData.content || foodData.desc || "";
|
|
||||||
|
|
||||||
const handleBackToMenu = () => {
|
const handleBackToMenu = () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -129,51 +159,40 @@ function FoodPage({}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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
|
<div
|
||||||
className={clsx(
|
className={glassSurface(
|
||||||
"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",
|
"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",
|
||||||
!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">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<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}
|
src={foodImage}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = "/assets/images/no-image.png";
|
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="absolute top-4 left-0 z-10 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">
|
<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>
|
||||||
{categoryName || "-"}
|
<button onClick={handleBackToMenu} className={glassSurfaceFlat("p-2 rounded-full")}>
|
||||||
</div>
|
<ArrowLeft size={18} className="stroke-primary dark:stroke-foreground" />
|
||||||
<button
|
|
||||||
onClick={handleBackToMenu}
|
|
||||||
className="p-2 rounded-full bg-container/40"
|
|
||||||
>
|
|
||||||
<ArrowLeft
|
|
||||||
size={18}
|
|
||||||
className="stroke-primary dark:stroke-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="w-full inline-flex justify-between items-center shrink-0">
|
||||||
<h5 className="text-base font-bold">{foodName}</h5>
|
<h5 className="text-base font-bold">{foodName}</h5>
|
||||||
<button
|
<button onClick={handleToggleFavorite} className="p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg">
|
||||||
onClick={handleToggleFavorite}
|
<Heart variant={isFavorite ? "Bold" : "Outline"} size={24} color="currentColor" className={isFavorite ? "fill-primary dark:fill-foreground" : ""} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,10 +200,7 @@ function FoodPage({}: Props) {
|
|||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Clock size={14} className="stroke-disabled-text" />
|
<Clock size={14} className="stroke-disabled-text" />
|
||||||
<span className="text-disabled-text">
|
<span className="text-disabled-text">زمان پخت و آماده سازی: {prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : "-"}</span>
|
||||||
زمان پخت و آماده سازی:{" "}
|
|
||||||
{prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<Cup size={14} className="stroke-disabled-text" />
|
<Cup size={14} className="stroke-disabled-text" />
|
||||||
@@ -194,18 +210,9 @@ function FoodPage({}: Props) {
|
|||||||
<div>۰</div>
|
<div>۰</div>
|
||||||
<span>امتیاز برای هر بار خرید</span>
|
<span>امتیاز برای هر بار خرید</span>
|
||||||
</>
|
</>
|
||||||
) : about?.data?.score?.purchaseScore &&
|
) : about?.data?.score?.purchaseScore && about?.data?.score?.purchaseAmount ? (
|
||||||
about?.data?.score?.purchaseAmount ? (
|
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>{ef(Math.round(foodData?.price * (Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount))).toLocaleString("en-US"))}</div>
|
||||||
{ef(
|
|
||||||
Math.round(
|
|
||||||
foodData?.price *
|
|
||||||
(Number(about.data.score.purchaseScore) /
|
|
||||||
Number(about.data.score.purchaseAmount)),
|
|
||||||
).toLocaleString("en-US"),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span>امتیاز برای هر بار خرید</span>
|
<span>امتیاز برای هر بار خرید</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -215,9 +222,7 @@ function FoodPage({}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<TruckTick size={14} className="stroke-disabled-text" />
|
<TruckTick size={14} className="stroke-disabled-text" />
|
||||||
<span className="text-disabled-text">
|
<span className="text-disabled-text">{foodData.pickupServe ? "ارسال با پیک" : "-"}</span>
|
||||||
{foodData.pickupServe ? "ارسال با پیک" : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,41 +236,26 @@ function FoodPage({}: Props) {
|
|||||||
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
|
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={isAvailable ? { scale: 1.05 } : undefined}
|
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 ? (
|
{quantity <= 0 ? (
|
||||||
<button
|
<button onClick={handleAddToCart} disabled={!isAvailable} className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none">
|
||||||
onClick={handleAddToCart}
|
{isAvailable && <PlusIcon />}
|
||||||
disabled={!isAvailable}
|
<div className="text-sm2 pt-0.5 font-normal text-foreground">{isAvailable ? "افزودن" : "عدم موجودی"}</div>
|
||||||
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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
disabled={!isAvailable}
|
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" />
|
<PlusIcon className="text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
<motion.div
|
<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">
|
||||||
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}
|
{quantity}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<button
|
<button onClick={handleRemoveFromCart} className={glassSurfaceFlat("hover:brightness-95 active:brightness-90 rounded-sm p-2 transition-all")}>
|
||||||
onClick={handleRemoveFromCart}
|
|
||||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
|
|
||||||
>
|
|
||||||
<MinusIcon className="text-foreground" />
|
<MinusIcon className="text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -274,6 +264,11 @@ function FoodPage({}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 { api } from "@/config/axios";
|
||||||
import { FoodResponse } from "../../types/Types";
|
import { FoodResponse } from "../../types/Types";
|
||||||
|
import { CreateReviewType, FoodReviewsResponse } from "../types/ReviewTypes";
|
||||||
|
|
||||||
export const getFood = async (id: string): Promise<FoodResponse> => {
|
export const getFood = async (id: string): Promise<FoodResponse> => {
|
||||||
const { data } = await api.get<FoodResponse>(`/public/foods/${id}`);
|
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}`);
|
const { data } = await api.post(`/public/foods/favorite/${foodId}`);
|
||||||
return data;
|
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'
|
"use client";
|
||||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
import AnimatedBottomSheet from "@/components/bottomsheet/AnimatedBottomSheet";
|
||||||
import EqualizerIcon from '@/components/icons/EqualizerIcon';
|
import EqualizerIcon from "@/components/icons/EqualizerIcon";
|
||||||
import TelegramIcon from '@/components/icons/TelegramIcon';
|
import TelegramIcon from "@/components/icons/TelegramIcon";
|
||||||
import TabContainer from '@/components/tab/TabContainer';
|
import TabContainer from "@/components/tab/TabContainer";
|
||||||
import { TabHeader } from '@/components/tab/TabHeader';
|
import { TabHeader } from "@/components/tab/TabHeader";
|
||||||
import Comment from '@/components/utils/Comment';
|
import Comment from "@/components/utils/Comment";
|
||||||
import RateBar from '@/components/utils/RateBar';
|
import RateBar from "@/components/utils/RateBar";
|
||||||
import useToggle from '@/hooks/helpers/useToggle';
|
import useToggle from "@/hooks/helpers/useToggle";
|
||||||
import { ArrowDown2, CallCalling, Clock, Gallery, InfoCircle, Instagram, Location, Star1, Whatsapp } from 'iconsax-react';
|
import { glassSurfaceCard, glassSurfaceCardFlat, glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||||
import { useQueryState } from 'next-usequerystate';
|
import { ArrowDown2, CallCalling, Clock, Gallery, InfoCircle, Instagram, Location, Star1, Whatsapp } from "iconsax-react";
|
||||||
import Image from 'next/image';
|
import { useQueryState } from "next-usequerystate";
|
||||||
import React from 'react'
|
import Image from "next/image";
|
||||||
import { useGetAbout, useGetReviews, useGetSchedules } from './hooks/useAboutData';
|
import AboutSkeleton from "./components/AboutSkeleton";
|
||||||
import AboutSkeleton from './components/AboutSkeleton';
|
import { useGetAbout, useGetReviews, useGetSchedules } from "./hooks/useAboutData";
|
||||||
|
import { isReviewApproved } from "./types/Types";
|
||||||
|
|
||||||
const sortings = [
|
const sortings = ["جدیدترین", "قدیمی ترین"];
|
||||||
'جدیدترین',
|
|
||||||
'قدیمی ترین'
|
|
||||||
]
|
|
||||||
|
|
||||||
function AboutPage() {
|
function AboutPage() {
|
||||||
|
|
||||||
const { data: aboutData, isLoading: aboutLoading } = useGetAbout();
|
const { data: aboutData, isLoading: aboutLoading } = useGetAbout();
|
||||||
const { data: reviewsData, isLoading: reviewsLoading } = useGetReviews();
|
const { data: reviewsData, isLoading: reviewsLoading } = useGetReviews();
|
||||||
const { data: schedulesData, isLoading: schedulesLoading } = useGetSchedules();
|
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 { state: sortingModal, toggle: toggleSortingModal, set: setSortingModal } = useToggle();
|
||||||
|
|
||||||
const restaurant = aboutData?.data;
|
const restaurant = aboutData?.data;
|
||||||
const schedules = schedulesData?.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;
|
const isLoading = aboutLoading || reviewsLoading || schedulesLoading;
|
||||||
|
|
||||||
@@ -39,22 +37,22 @@ function AboutPage() {
|
|||||||
const changeSorting = (index: number) => {
|
const changeSorting = (index: number) => {
|
||||||
setSorting(() => String(index));
|
setSorting(() => String(index));
|
||||||
setSortingModal(false);
|
setSortingModal(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatTime = (time: string): string => {
|
const formatTime = (time: string): string => {
|
||||||
return time.substring(0, 5);
|
return time.substring(0, 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDayName = (weekDay: number): string => {
|
const getDayName = (weekDay: number): string => {
|
||||||
const days = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
const days = ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"];
|
||||||
return days[weekDay] || '';
|
return days[weekDay] || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupSchedulesByDay = () => {
|
const groupSchedulesByDay = () => {
|
||||||
const grouped: Record<number, typeof schedules> = {};
|
const grouped: Record<number, typeof schedules> = {};
|
||||||
schedules
|
schedules
|
||||||
.filter(schedule => schedule.isActive)
|
.filter((schedule) => schedule.isActive)
|
||||||
.forEach(schedule => {
|
.forEach((schedule) => {
|
||||||
if (!grouped[schedule.weekDay]) {
|
if (!grouped[schedule.weekDay]) {
|
||||||
grouped[schedule.weekDay] = [];
|
grouped[schedule.weekDay] = [];
|
||||||
}
|
}
|
||||||
@@ -65,9 +63,7 @@ function AboutPage() {
|
|||||||
|
|
||||||
const getTodaySchedule = () => {
|
const getTodaySchedule = () => {
|
||||||
const today = new Date().getDay();
|
const today = new Date().getDay();
|
||||||
const todaySchedules = schedules.filter(schedule =>
|
const todaySchedules = schedules.filter((schedule) => schedule.isActive && schedule.weekDay === today);
|
||||||
schedule.isActive && schedule.weekDay === today
|
|
||||||
);
|
|
||||||
if (todaySchedules.length > 0) {
|
if (todaySchedules.length > 0) {
|
||||||
const firstSchedule = todaySchedules[0];
|
const firstSchedule = todaySchedules[0];
|
||||||
return `${formatTime(firstSchedule.openTime)} تا ${formatTime(firstSchedule.closeTime)}`;
|
return `${formatTime(firstSchedule.openTime)} تا ${formatTime(firstSchedule.closeTime)}`;
|
||||||
@@ -79,102 +75,90 @@ function AboutPage() {
|
|||||||
if (!restaurant) return null;
|
if (!restaurant) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="about-title" className='py-4'>
|
<section aria-labelledby="about-title" className="py-4">
|
||||||
<section
|
<section className={glassSurfaceCard("p-4")}>
|
||||||
className="bg-container rounded-container shadow-container p-4">
|
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-6.25">
|
||||||
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
|
|
||||||
<div className="">
|
<div className="">
|
||||||
<h2 className='text-sm2 font-bold leading-5'>{restaurant.name}</h2>
|
<h2 className="text-sm2 font-bold leading-5">{restaurant.name}</h2>
|
||||||
{restaurant.establishedYear && (
|
{restaurant.establishedYear && <p className="text-sm2 leading-5 mt-4">تاسیس: {restaurant.establishedYear}</p>}
|
||||||
<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>}
|
||||||
)}
|
|
||||||
{restaurant.tagNames && restaurant.tagNames.length > 0 && (
|
|
||||||
<p className="text-sm2 leading-5 mt-2">نوع محصولات: {restaurant.tagNames.join('، ')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{restaurant.logo && (
|
{restaurant.logo && (
|
||||||
<div className="rounded-normal overflow-clip">
|
<div className="rounded-normal overflow-clip">
|
||||||
<Image
|
<Image alt="logo" src={restaurant.logo} width={88} height={88} unoptimized priority />
|
||||||
alt='logo'
|
|
||||||
src={restaurant.logo}
|
|
||||||
width={88}
|
|
||||||
height={88}
|
|
||||||
unoptimized
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{restaurant.description && (
|
{restaurant.description && (
|
||||||
<div className="mt-[23px]">
|
<div className="mt-5.75">
|
||||||
<h3 className="text-sm2 font-bold leading-5">درباره مجموعه</h3>
|
<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}
|
{restaurant.description}
|
||||||
</p>
|
</p>
|
||||||
{restaurant.images && restaurant.images.length > 0 && (
|
{restaurant.images && restaurant.images.length > 0 && (
|
||||||
<div className='inline-flex gap-2 mt-[23px] items-center'>
|
<div className="inline-flex gap-2 mt-5.75 items-center">
|
||||||
<Gallery size={20} className='stroke-disabled-text' />
|
<Gallery size={20} className="stroke-disabled-text" />
|
||||||
<span className='text-sm2 text-disabled-text font-medium pt-0.5'>عکس های رستوران</span>
|
<span className="text-sm2 text-disabled-text font-medium pt-0.5">عکس های رستوران</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(restaurant.phone || restaurant.telegram || restaurant.whatsapp || restaurant.instagram) && (
|
{(phoneList.length > 0 || restaurant.telegram || restaurant.whatsapp || restaurant.instagram) && (
|
||||||
<section
|
<section className={glassSurfaceCard("pt-3 pb-3.5 px-4 mt-4 grid grid-cols-3 items-center")}>
|
||||||
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>
|
||||||
<h2 className='text-sm2 font-medium leading-5'>ارتباط</h2>
|
<div className="col-span-2 text-center flex justify-center flex-wrap gap-2">
|
||||||
<div className='col-span-1 text-center flex justify-center gap-4'>
|
{phoneList.map((phone) => (
|
||||||
{restaurant.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">
|
||||||
<a href={`tel://${restaurant.phone}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
<CallCalling className="stroke-foreground" size={24} />
|
||||||
<CallCalling className='stroke-foreground' size={24} />
|
<span dir="ltr" className="text-xs leading-5">
|
||||||
|
{phone}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
))}
|
||||||
{restaurant.telegram &&
|
{restaurant.telegram && (
|
||||||
<a href={`https://t.me/${restaurant.telegram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
<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} />
|
<TelegramIcon className="stroke-foreground" width={24} height={24} />
|
||||||
</a>
|
</a>
|
||||||
}
|
)}
|
||||||
{restaurant.whatsapp &&
|
{restaurant.whatsapp && (
|
||||||
<a href={`https://whatsapp.com/?phone=${restaurant.whatsapp}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
<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} />
|
<Whatsapp className="stroke-foreground" size={24} />
|
||||||
</a>
|
</a>
|
||||||
}
|
)}
|
||||||
{restaurant.instagram &&
|
{restaurant.instagram && (
|
||||||
<a href={`https://instagram.com/${restaurant.instagram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
|
<a href={`https://instagram.com/${restaurant.instagram}`} className="bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal">
|
||||||
<Instagram className='stroke-foreground' size={24} />
|
<Instagram className="stroke-foreground" size={24} />
|
||||||
</a>
|
</a>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restaurant.address && (
|
{restaurant.address && (
|
||||||
<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")}>
|
||||||
<h2 className='text-sm2 font-medium leading-5'>آدرس</h2>
|
<h2 className="text-sm2 font-medium leading-5">آدرس</h2>
|
||||||
<p className='text-sm2 mt-[9px] leading-5'>{restaurant.address}</p>
|
<p className="text-sm2 mt-2.25 leading-5">{restaurant.address}</p>
|
||||||
{restaurant.latitude && restaurant.longitude && (
|
{restaurant.latitude && restaurant.longitude && (
|
||||||
<div className='inline-flex gap-2 mt-[23px] items-center '>
|
<div className="inline-flex gap-2 mt-5.75 items-center ">
|
||||||
<Location size={20} className='stroke-disabled-text' />
|
<Location size={20} className="stroke-disabled-text" />
|
||||||
<span className='text-sm2 text-disabled-text '>موقعیت روی نقشه</span>
|
<span className="text-sm2 text-disabled-text ">موقعیت روی نقشه</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedules.length > 0 && getTodaySchedule() && (
|
{schedules.length > 0 && getTodaySchedule() && (
|
||||||
<section
|
<section aria-label="ساعات کاری" className={glassSurfaceCard("py-6 px-4 mt-4 flex justify-between items-center")}>
|
||||||
aria-label='ساعات کاری'
|
|
||||||
className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-2 justify-start">
|
<div className="flex items-center gap-2 justify-start">
|
||||||
<Clock size={16} className='stroke-disabled-text' />
|
<Clock size={16} className="stroke-disabled-text" />
|
||||||
<div className='text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]'>باز</div>
|
<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="size-1.5 bg-[#D9D9D9] rounded-full"></div>
|
||||||
<div className='text-sm2 mt-0.5'>امروز از ساعت {getTodaySchedule()}</div>
|
<div className="text-sm2 mt-0.5">امروز از ساعت {getTodaySchedule()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div className="">
|
||||||
<ArrowDown2 size={16} className='stroke-[#292D32]' />
|
<ArrowDown2 size={16} className="stroke-[#292D32]" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -184,18 +168,13 @@ function AboutPage() {
|
|||||||
{Object.entries(groupSchedulesByDay())
|
{Object.entries(groupSchedulesByDay())
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
.map(([weekDay, daySchedules]) => (
|
.map(([weekDay, daySchedules]) => (
|
||||||
<div
|
<div key={weekDay} className={glassSurfaceCardFlat("leading-5 py-6 px-4 mt-4 flex justify-between items-center")}>
|
||||||
key={weekDay}
|
<div className="text-sm2">{getDayName(Number(weekDay))}</div>
|
||||||
style={{ boxShadow: '0px 0px 14px 0px #0000000F' }}
|
<div className="text-sm2 font-light">
|
||||||
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'>
|
|
||||||
{daySchedules.map((schedule, index) => (
|
{daySchedules.map((schedule, index) => (
|
||||||
<span key={schedule.id}>
|
<span key={schedule.id}>
|
||||||
{formatTime(schedule.openTime)} - {formatTime(schedule.closeTime)}
|
{formatTime(schedule.openTime)} - {formatTime(schedule.closeTime)}
|
||||||
{index < daySchedules.length - 1 && '، '}
|
{index < daySchedules.length - 1 && "، "}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,23 +183,23 @@ function AboutPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('fa-IR', {
|
return date.toLocaleDateString("fa-IR", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondTab = () => {
|
const secondTab = () => {
|
||||||
const reviews = (reviewsData?.data || []).filter(review => review.isApproved);
|
const reviews = (reviewsData?.data || []).filter(isReviewApproved);
|
||||||
|
|
||||||
const sortedReviews = [...reviews].sort((a, b) => {
|
const sortedReviews = [...reviews].sort((a, b) => {
|
||||||
if (sorting === '0') {
|
if (sorting === "0") {
|
||||||
// جدیدترین
|
// جدیدترین
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
} else {
|
} else {
|
||||||
@@ -229,16 +208,14 @@ function AboutPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const averageRating = reviews.length > 0
|
const averageRating = reviews.length > 0 ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length : 0;
|
||||||
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const ratingCounts = {
|
const ratingCounts = {
|
||||||
5: reviews.filter(r => r.rating === 5).length,
|
5: reviews.filter((r) => r.rating === 5).length,
|
||||||
4: reviews.filter(r => r.rating === 4).length,
|
4: reviews.filter((r) => r.rating === 4).length,
|
||||||
3: reviews.filter(r => r.rating === 3).length,
|
3: reviews.filter((r) => r.rating === 3).length,
|
||||||
2: reviews.filter(r => r.rating === 2).length,
|
2: reviews.filter((r) => r.rating === 2).length,
|
||||||
1: reviews.filter(r => r.rating === 1).length,
|
1: reviews.filter((r) => r.rating === 1).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalReviews = reviews.length;
|
const totalReviews = reviews.length;
|
||||||
@@ -251,27 +228,23 @@ function AboutPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="reviews-title" className='py-4'>
|
<section aria-labelledby="reviews-title" className="py-4">
|
||||||
<section
|
<section aria-label="امتیاز کاربران" className={glassSurfaceCard("p-4 py-6 grid grid-cols-2 items-center")}>
|
||||||
aria-label='امتیاز کاربران'
|
<div className="text-center font-bold text-5xl">{averageRating.toFixed(1)}</div>
|
||||||
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>
|
|
||||||
<div className="flex flex-col w-fit items-end">
|
<div className="flex flex-col w-fit items-end">
|
||||||
<RateBar className='mt-1' content='5' percentage={String(ratingPercentages[5])} />
|
<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="4" percentage={String(ratingPercentages[4])} />
|
||||||
<RateBar className='mt-1' content='3' percentage={String(ratingPercentages[3])} />
|
<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="2" percentage={String(ratingPercentages[2])} />
|
||||||
<RateBar className='mt-1' content='1' percentage={String(ratingPercentages[1])} />
|
<RateBar className="mt-1" content="1" percentage={String(ratingPercentages[1])} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
|
||||||
<h2 className='text-sm2 font-medium leading-5'>نظرات کاربران</h2>
|
<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]">
|
<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' />
|
<EqualizerIcon className="dark:text-white" />
|
||||||
<span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span>
|
<span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,8 +253,8 @@ function AboutPage() {
|
|||||||
sortedReviews.map((review) => (
|
sortedReviews.map((review) => (
|
||||||
<article key={review.id}>
|
<article key={review.id}>
|
||||||
<Comment
|
<Comment
|
||||||
className='pt-8'
|
className="pt-8"
|
||||||
user={review.user?.firstName + ' ' + review.user?.lastName}
|
user={review.user?.firstName + " " + review.user?.lastName}
|
||||||
rating={review.rating}
|
rating={review.rating}
|
||||||
date={formatDate(review.createdAt)}
|
date={formatDate(review.createdAt)}
|
||||||
text={review.comment}
|
text={review.comment}
|
||||||
@@ -292,7 +265,7 @@ function AboutPage() {
|
|||||||
</article>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -306,29 +279,22 @@ function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
{i < sortings.length - 1 && <hr className="border-white/40 dark:border-border mb-4 mt-4" />}
|
{i < sortings.length - 1 && <hr className="border-white/40 dark:border-border mb-4 mt-4" />}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</AnimatedBottomSheet>
|
</AnimatedBottomSheet>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='pt-8 h-full overflow-y-auto noscrollbar'>
|
<div className="pt-8 h-full overflow-y-auto noscrollbar">
|
||||||
<TabContainer>
|
<TabContainer>
|
||||||
<TabHeader
|
<TabHeader viewRenderer={firstTab()} title="درباره ما" icon={<InfoCircle size={24} />}></TabHeader>
|
||||||
viewRenderer={firstTab()}
|
<TabHeader viewRenderer={secondTab()} title="امتیازات و نظرات" icon={<Star1 size={24} />}></TabHeader>
|
||||||
title='درباره ما' icon={<InfoCircle size={24} />}>
|
|
||||||
</TabHeader>
|
|
||||||
<TabHeader
|
|
||||||
viewRenderer={secondTab()}
|
|
||||||
title='امتیازات و نظرات' icon={<Star1 size={24} />}>
|
|
||||||
</TabHeader>
|
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default AboutPage;
|
||||||
export default AboutPage
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import TabContainer from "@/components/tab/TabContainer";
|
import TabContainer from "@/components/tab/TabContainer";
|
||||||
import { TabHeader } from "@/components/tab/TabHeader";
|
import { TabHeader } from "@/components/tab/TabHeader";
|
||||||
import { InfoCircle, Star1 } from 'iconsax-react';
|
import { InfoCircle, Star1 } from 'iconsax-react';
|
||||||
|
import { glassSurfaceCard, glassSurfaceCardFlat } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
const AboutSkeleton = () => {
|
const AboutSkeleton = () => {
|
||||||
const firstTabSkeleton = () => (
|
const firstTabSkeleton = () => (
|
||||||
<section className='py-4'>
|
<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 justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Skeleton className="h-6 w-32 rounded-md mb-4" />
|
<Skeleton className="h-6 w-32 rounded-md mb-4" />
|
||||||
@@ -31,7 +32,7 @@ const AboutSkeleton = () => {
|
|||||||
</section>
|
</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" />
|
<Skeleton className="h-5 w-16 rounded-md" />
|
||||||
<div className='col-span-1 text-center flex justify-center gap-4'>
|
<div className='col-span-1 text-center flex justify-center gap-4'>
|
||||||
{[...Array(3)].map((_, index) => (
|
{[...Array(3)].map((_, index) => (
|
||||||
@@ -41,7 +42,7 @@ const AboutSkeleton = () => {
|
|||||||
</section>
|
</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-5 w-12 rounded-md mb-[9px]" />
|
||||||
<Skeleton className="h-4 w-full rounded-md mb-2" />
|
<Skeleton className="h-4 w-full rounded-md mb-2" />
|
||||||
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
|
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
|
||||||
@@ -52,7 +53,7 @@ const AboutSkeleton = () => {
|
|||||||
</section>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="w-4 h-4 rounded-full" />
|
<Skeleton className="w-4 h-4 rounded-full" />
|
||||||
<Skeleton className="h-5 w-8 rounded-md" />
|
<Skeleton className="h-5 w-8 rounded-md" />
|
||||||
@@ -66,7 +67,7 @@ const AboutSkeleton = () => {
|
|||||||
{[...Array(3)].map((_, index) => (
|
{[...Array(3)].map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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-20 rounded-md" />
|
||||||
<Skeleton className="h-5 w-24 rounded-md" />
|
<Skeleton className="h-5 w-24 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +78,7 @@ const AboutSkeleton = () => {
|
|||||||
const secondTabSkeleton = () => (
|
const secondTabSkeleton = () => (
|
||||||
<section className='py-4'>
|
<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" />
|
<Skeleton className="h-16 w-20 rounded-md mx-auto" />
|
||||||
<div className="flex flex-col w-fit items-end gap-1">
|
<div className="flex flex-col w-fit items-end gap-1">
|
||||||
{[...Array(5)].map((_, index) => (
|
{[...Array(5)].map((_, index) => (
|
||||||
@@ -87,7 +88,7 @@ const AboutSkeleton = () => {
|
|||||||
</section>
|
</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">
|
<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-5 w-24 rounded-md" />
|
||||||
<Skeleton className="h-8 w-28 rounded-xl" />
|
<Skeleton className="h-8 w-28 rounded-xl" />
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as api from "../service/AboutService";
|
|
||||||
import { useParams } from "next/navigation";
|
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 = () => {
|
export const useGetAbout = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["about", name],
|
queryKey: ["about", name],
|
||||||
queryFn: () => api.getAbout(name),
|
queryFn: async () => {
|
||||||
|
const data = await api.getAbout(name);
|
||||||
|
setPersistedAboutData(name, data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 60 * 60_000,
|
||||||
refetchOnMount: false,
|
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 = () => {
|
export const useGetReviews = () => {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export interface Restaurant {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
establishedYear: number | null;
|
establishedYear: number | null;
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
phone: string;
|
phone?: string | null;
|
||||||
|
phones?: string[];
|
||||||
instagram: string | null;
|
instagram: string | null;
|
||||||
telegram: string | null;
|
telegram: string | null;
|
||||||
whatsapp: string | null;
|
whatsapp: string | null;
|
||||||
@@ -44,55 +45,73 @@ export interface Restaurant {
|
|||||||
vat: number;
|
vat: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
score: Score;
|
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 {
|
export interface ReviewFood {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt?: string;
|
||||||
updatedAt: string;
|
updatedAt?: string;
|
||||||
deletedAt: string | null;
|
deletedAt?: string | null;
|
||||||
restaurant: string;
|
restaurant?: string;
|
||||||
category: string;
|
category?: string;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string | null;
|
desc?: string | null;
|
||||||
content: string | null;
|
content?: string[] | string | null;
|
||||||
price: number;
|
price?: number;
|
||||||
points: number | null;
|
points?: number | null;
|
||||||
order: number | null;
|
order?: number | null;
|
||||||
prepareTime: number | null;
|
prepareTime?: number | null;
|
||||||
sat: boolean;
|
weekDays?: number[];
|
||||||
sun: boolean;
|
mealTypes?: string[];
|
||||||
mon: boolean;
|
sat?: boolean;
|
||||||
breakfast: boolean;
|
sun?: boolean;
|
||||||
noon: boolean;
|
mon?: boolean;
|
||||||
dinner: boolean;
|
breakfast?: boolean;
|
||||||
stock: number;
|
noon?: boolean;
|
||||||
stockDefault: number;
|
dinner?: boolean;
|
||||||
isActive: boolean;
|
stock?: number;
|
||||||
images: string[];
|
stockDefault?: number;
|
||||||
inPlaceServe: boolean;
|
isActive?: boolean;
|
||||||
pickupServe: boolean;
|
images?: string[];
|
||||||
rate: number;
|
inPlaceServe?: boolean;
|
||||||
discount: number;
|
pickupServe?: boolean;
|
||||||
isSpecialOffer: boolean;
|
rate?: number;
|
||||||
|
score?: number;
|
||||||
|
discount?: number;
|
||||||
|
isSpecialOffer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewUser {
|
export interface ReviewUser {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt?: string;
|
||||||
updatedAt: string;
|
updatedAt?: string;
|
||||||
deletedAt: string | null;
|
deletedAt?: string | null;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
birthDate: string;
|
birthDate?: string;
|
||||||
marriageDate: string;
|
marriageDate?: string;
|
||||||
referrer: string | null;
|
referrer?: string | null;
|
||||||
isActive: boolean;
|
isActive?: boolean;
|
||||||
gender: boolean;
|
gender?: boolean;
|
||||||
wallet: number;
|
wallet?: number;
|
||||||
points: number;
|
points?: number;
|
||||||
phone: string;
|
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 {
|
export interface Review {
|
||||||
@@ -100,14 +119,26 @@ export interface Review {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
order: string;
|
isBuyer?: boolean;
|
||||||
|
order?: string;
|
||||||
food: ReviewFood;
|
food: ReviewFood;
|
||||||
user: ReviewUser;
|
user?: ReviewUser | null;
|
||||||
|
admin?: ReviewAdmin | null;
|
||||||
comment: string;
|
comment: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
positivePoints: string[];
|
positivePoints: string[];
|
||||||
negativePoints: 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 {
|
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 CartSummary from "./components/CartSummary";
|
||||||
import { useCart } from "./hook/useCart";
|
import { useCart } from "./hook/useCart";
|
||||||
import { useCartPageState } from "./hooks/useCartPageState";
|
import { useCartPageState } from "./hooks/useCartPageState";
|
||||||
|
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||||
|
|
||||||
const CartPage = () => {
|
const CartPage = () => {
|
||||||
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
||||||
@@ -42,7 +43,7 @@ const CartPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-3 items-center mt-6">
|
||||||
{isCartEmpty || isLoading ? (
|
{isCartEmpty || isLoading ? (
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -93,7 +94,7 @@ const CartPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 pb-24">
|
<div className="flex flex-col gap-4 pb-24">
|
||||||
{!isPremium && (
|
{!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="flex items-start gap-3 mb-4">
|
||||||
<div className="shrink-0 mt-0.5">
|
<div className="shrink-0 mt-0.5">
|
||||||
<NotificationBellIcon
|
<NotificationBellIcon
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import { useGetFoods } from "@/app/[name]/(Main)/hooks/useMenuData";
|
||||||
import Link from 'next/link';
|
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
import Button from "@/components/button/PrimaryButton";
|
||||||
import Button from '@/components/button/PrimaryButton';
|
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
import Link from "next/link";
|
||||||
import { useCart } from '../hook/useCart';
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslation } from 'react-i18next';
|
import React from "react";
|
||||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetCartItems } from '../hooks/useCartData';
|
import { useCart } from "../hook/useCart";
|
||||||
import { useCartStore } from '../store/Store';
|
import { useGetCartItems } from "../hooks/useCartData";
|
||||||
import { guestCartItemsToFoods } from '../lib/cartUtils';
|
import { guestCartItemsToFoods } from "../lib/cartUtils";
|
||||||
|
import { useCartStore } from "../store/Store";
|
||||||
|
|
||||||
interface CartSummaryProps {
|
interface CartSummaryProps {
|
||||||
isPremium: boolean;
|
isPremium: boolean;
|
||||||
@@ -26,7 +27,7 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
|||||||
const { data: foodsResponse } = useGetFoods();
|
const { data: foodsResponse } = useGetFoods();
|
||||||
const { items } = useCart();
|
const { items } = useCart();
|
||||||
const { data: cartData } = useGetCartItems(isSuccess);
|
const { data: cartData } = useGetCartItems(isSuccess);
|
||||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
||||||
const { description, setDescription } = useCartStore();
|
const { description, setDescription } = useCartStore();
|
||||||
|
|
||||||
const totalPrice = React.useMemo(() => {
|
const totalPrice = React.useMemo(() => {
|
||||||
@@ -45,10 +46,7 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [isSuccess, cartData?.data?.total, foodsResponse?.data, items]);
|
}, [isSuccess, cartData?.data?.total, foodsResponse?.data, items]);
|
||||||
|
|
||||||
const formatPrice = React.useCallback(
|
const formatPrice = React.useCallback((value: number) => value.toLocaleString("fa-IR"), []);
|
||||||
(value: number) => value.toLocaleString('fa-IR'),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasItems) {
|
if (!hasItems) {
|
||||||
return null;
|
return null;
|
||||||
@@ -57,36 +55,32 @@ const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isPremium && (
|
{isPremium && (
|
||||||
<div className='mt-2 mb-4'>
|
<div className="mt-2 mb-4">
|
||||||
<div className='relative'>
|
<div className="relative">
|
||||||
<h3 className='text-sm'>{t('InputDescription.Label')}</h3>
|
<h3 className="text-sm">{t("InputDescription.Label")}</h3>
|
||||||
<textarea
|
<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'
|
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')}
|
placeholder={t("InputDescription.Placeholder")}
|
||||||
id='description'
|
id="description"
|
||||||
name='description'
|
name="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
></textarea>
|
></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>
|
</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={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 justify-between items-center gap-4">
|
||||||
<div className='flex flex-col'>
|
<div className="flex flex-col">
|
||||||
<div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div>
|
<div className="text-xs text-gray-400 dark:text-disabled-text">{t("PayableAmountLabel")}</div>
|
||||||
{isPremium && (
|
{isPremium && <div className="text-sm mt-2 font-semibold dark:text-foreground">{formatPrice(totalPrice)} تومان</div>}
|
||||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
|
||||||
{formatPrice(totalPrice)} تومان
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPremium && (
|
{isPremium && (
|
||||||
<Link
|
<Link
|
||||||
href={'order/checkout/0'}
|
href={"order/checkout/0"}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
event.preventDefault();
|
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'>
|
<Button className="w-fit! px-10 dark:bg-white dark:text-black! dark:hover:bg-gray-100">{t("ButtonCheckout")}</Button>
|
||||||
{t('ButtonCheckout')}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{!isPremium && (
|
{!isPremium && <div className="text-sm mt-2 font-semibold dark:text-foreground">{formatPrice(totalPrice)} تومان</div>}
|
||||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
|
||||||
{formatPrice(totalPrice)} تومان
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ef } from '@/lib/helpers/utfNumbers'
|
|||||||
import { EmojiHappy, Microphone2, Paperclip2 } from 'iconsax-react'
|
import { EmojiHappy, Microphone2, Paperclip2 } from 'iconsax-react'
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface'
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ function ChatIndex ({}: Props) {
|
|||||||
<section className='flex flex-col h-full pt-6'>
|
<section className='flex flex-col h-full pt-6'>
|
||||||
<h1 className='font-medium'>علی مصلحی</h1>
|
<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'>
|
<div className='overflow-y-auto flex flex-col-reverse'>
|
||||||
<ul className='grid gap-6 pb-6 '>
|
<ul className='grid gap-6 pb-6 '>
|
||||||
{messagesMemo.map((v, i) => {
|
{messagesMemo.map((v, i) => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useToggle from '@/hooks/helpers/useToggle'
|
import useToggle from '@/hooks/helpers/useToggle'
|
||||||
import Prompt from '@/components/utils/Prompt'
|
import Prompt from '@/components/utils/Prompt'
|
||||||
|
import { glassSurfaceFlat } from '@/lib/styles/glassSurface'
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ function ChatIndex ({}: Props) {
|
|||||||
{selectedChats.length > 0 ? (
|
{selectedChats.length > 0 ? (
|
||||||
<span
|
<span
|
||||||
onClick={toggleDeleteModal}
|
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
|
<Trash
|
||||||
size={24}
|
size={24}
|
||||||
@@ -180,7 +181,7 @@ function ChatIndex ({}: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={'chat/nearby'}
|
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
|
<Radar2
|
||||||
size={24}
|
size={24}
|
||||||
@@ -198,7 +199,7 @@ function ChatIndex ({}: Props) {
|
|||||||
<li
|
<li
|
||||||
key={i}
|
key={i}
|
||||||
{...longPress(chat.id)}
|
{...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]'>
|
<div className='size-[49px] min-w-[49px]'>
|
||||||
{selectedChats.includes(chat.id) ? (
|
{selectedChats.includes(chat.id) ? (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Category } from "@/app/[name]/(Main)/types/Types";
|
|||||||
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
|
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
|
||||||
import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer";
|
import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer";
|
||||||
import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
|
import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
|
||||||
|
import { GLASS_SURFACE_DISABLED, GLASS_SURFACE_SELECTED } from "@/lib/styles/glassSurface";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
@@ -19,14 +20,14 @@ const SVG_MASK_STYLE = {
|
|||||||
WebkitMaskPosition: "center",
|
WebkitMaskPosition: "center",
|
||||||
} as const;
|
} 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 isSvg = src.endsWith(".svg");
|
||||||
const shouldUseSvgMask = isSvg && tintWithPrimary;
|
const shouldUseSvgMask = isSvg && tintWithPrimary;
|
||||||
|
|
||||||
if (shouldUseSvgMask) {
|
if (shouldUseSvgMask) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 bg-primary"
|
className={clsx("shrink-0 bg-primary", disabled && "opacity-40")}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -48,7 +49,7 @@ function CategoryImage({ src, size, alt, tintWithPrimary = true }: { src: string
|
|||||||
height={size}
|
height={size}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="shrink-0 object-contain"
|
className={clsx("shrink-0 object-contain", disabled && "opacity-40")}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = "/assets/images/food-image.png";
|
event.currentTarget.src = "/assets/images/food-image.png";
|
||||||
}}
|
}}
|
||||||
@@ -108,11 +109,17 @@ const CategoryScroll = ({ categories, selectedCategory, onSelect, variant = "lar
|
|||||||
</Renderer> */}
|
</Renderer> */}
|
||||||
{categories.map((item) => {
|
{categories.map((item) => {
|
||||||
const isSelected = item.id === selectedCategory;
|
const isSelected = item.id === selectedCategory;
|
||||||
|
const isDisabled = !item.isActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Renderer key={item.id} className={clsx(isSelected && "bg-container!")} onClick={handleSelect(item.id)}>
|
<Renderer
|
||||||
<CategoryImage src={item.avatarUrl || "/assets/images/food-image.png"} size={imageSize} alt="category image" tintWithPrimary={!usesColoredSvg} />
|
key={item.id}
|
||||||
<span className="text-xs text-foreground text-center">{item.title}</span>
|
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>
|
</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";
|
"use client";
|
||||||
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
|
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 MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
|
||||||
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
||||||
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
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 MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||||
import useToggle from "@/hooks/helpers/useToggle";
|
import useToggle from "@/hooks/helpers/useToggle";
|
||||||
|
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Candle2 } from "iconsax-react";
|
import { Candle2 } from "iconsax-react";
|
||||||
import { useQueryState } from "next-usequerystate";
|
import { useQueryState } from "next-usequerystate";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import type { Category, Food } from "../types/Types";
|
||||||
|
|
||||||
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
||||||
|
|
||||||
const MenuIndex = () => {
|
const MenuIndex = () => {
|
||||||
const { data: foodsData, isLoading: foodsLoading } = useGetFoods();
|
const { data: foodsData, isPending: foodsPending } = useGetFoods();
|
||||||
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories();
|
const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
|
||||||
|
const { data: slidersData } = useGetSliders();
|
||||||
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
||||||
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.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: tCommon } = useTranslation("common");
|
||||||
const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
|
const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
|
||||||
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
|
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
|
||||||
@@ -161,11 +165,17 @@ const MenuIndex = () => {
|
|||||||
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
|
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
|
||||||
|
<HomeSliderCarousel sliders={sliders} />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
|
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
|
||||||
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
|
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
|
||||||
@@ -173,13 +183,13 @@ const MenuIndex = () => {
|
|||||||
|
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
<div className="flex flex-wrap gap-2 items-center relative" ref={smallCategoriesRef}>
|
<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>
|
<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-[247px] flex-1 gap-2 justify-end items-center">
|
<div className="flex min-w-61.75 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]">
|
<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} />
|
<Candle2 className="stroke-foreground" size={16} />
|
||||||
<span className="text-xs leading-5 font-medium whitespace-nowrap">{tMenu("MenuFilterDrawer.Label")}</span>
|
<span className="text-xs leading-5 font-medium whitespace-nowrap">{tMenu("MenuFilterDrawer.Label")}</span>
|
||||||
</button>
|
</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" />
|
<TextAlignIcon className="text-foreground" />
|
||||||
<span className="text-xs leading-5 font-medium whitespace-nowrap">{activeSortingLabel}</span>
|
<span className="text-xs leading-5 font-medium whitespace-nowrap">{activeSortingLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -196,7 +206,7 @@ const MenuIndex = () => {
|
|||||||
opacity: smallCategoriesVisible ? 1 : 0,
|
opacity: smallCategoriesVisible ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.1 }}
|
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" />
|
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} variant="small" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
const MenuSkeleton = () => {
|
type Props = {
|
||||||
|
embedded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuSkeleton = ({ embedded = false }: Props) => {
|
||||||
return (
|
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">
|
<div className="w-full">
|
||||||
<Skeleton className="h-12 w-full rounded-xl mb-4" />
|
<Skeleton className="h-12 w-full rounded-xl mb-4" />
|
||||||
<div className="flex gap-3 overflow-x-hidden py-4">
|
<div className="flex gap-3 overflow-x-hidden py-4">
|
||||||
|
|||||||
@@ -1,28 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
import * as api from "../service/MenuService";
|
import * as api from "../service/MenuService";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { hasAuthToken } from "@/lib/api/func";
|
import { hasAuthToken } from "@/lib/api/func";
|
||||||
|
import {
|
||||||
|
getPersistedCategoriesData,
|
||||||
|
getPersistedMenuData,
|
||||||
|
setPersistedCategoriesData,
|
||||||
|
setPersistedMenuData,
|
||||||
|
} from "@/lib/helpers/themeCache";
|
||||||
|
|
||||||
export const useGetFoods = () => {
|
export const useGetFoods = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["menu", name],
|
queryKey: ["menu", name],
|
||||||
queryFn: () => api.getFoods(name),
|
queryFn: async () => {
|
||||||
|
const data = await api.getFoods(name);
|
||||||
|
setPersistedMenuData(name, data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
staleTime: 5 * 60_000,
|
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 = () => {
|
export const useGetCategories = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["categories", name],
|
queryKey: ["categories", name],
|
||||||
queryFn: () => api.getCategories(name),
|
queryFn: async () => {
|
||||||
|
const data = await api.getCategories(name);
|
||||||
|
setPersistedCategoriesData(name, data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
staleTime: 5 * 60_000,
|
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 = () => {
|
export const useGetNotificationsCount = () => {
|
||||||
@@ -32,3 +67,14 @@ export const useGetNotificationsCount = () => {
|
|||||||
enabled: hasAuthToken(),
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useGetOrders } from './hooks/useHistoryData';
|
import { useGetOrders } from './hooks/useHistoryData';
|
||||||
import { HistoryOrderItem } from './types/Types';
|
import { HistoryOrderItem } from './types/Types';
|
||||||
import { OrderStatus } from './enum/Enum';
|
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
const fallbackImage = '/assets/images/food-preview.png';
|
const fallbackImage = '/assets/images/food-preview.png';
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ function OrdersIndex() {
|
|||||||
const items: HistoryOrderItem[] = order.items || [];
|
const items: HistoryOrderItem[] = order.items || [];
|
||||||
|
|
||||||
return (
|
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="text-sm2">
|
||||||
<div className='flex items-center justify-between mb-2'>
|
<div className='flex items-center justify-between mb-2'>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
@@ -165,7 +165,7 @@ function OrdersIndex() {
|
|||||||
const items: HistoryOrderItem[] = order.items || [];
|
const items: HistoryOrderItem[] = order.items || [];
|
||||||
|
|
||||||
return (
|
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="text-sm2">
|
||||||
<div className='flex items-center justify-between mb-2'>
|
<div className='flex items-center justify-between mb-2'>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
@@ -220,11 +220,6 @@ function OrdersIndex() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -79,12 +79,10 @@ export interface HistoryOrderDeliveryMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryOrderUserAddress {
|
export interface HistoryOrderUserAddress {
|
||||||
city: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
province: string;
|
|
||||||
longitude: number;
|
longitude: number;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
}
|
}
|
||||||
@@ -97,8 +95,6 @@ export interface HistoryOrderAddress {
|
|||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
|
||||||
province: string;
|
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: 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,
|
CategoriesResponse,
|
||||||
FoodsResponse,
|
FoodsResponse,
|
||||||
NotificationsCountResponse,
|
NotificationsCountResponse,
|
||||||
|
SlidersResponse,
|
||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
|
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
|
||||||
@@ -29,3 +30,14 @@ export const getNotificationsCount = async (): Promise<
|
|||||||
);
|
);
|
||||||
return response.data;
|
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 { useGetAbout } from '../../../about/hooks/useAboutData';
|
||||||
import { toast } from '@/components/Toast';
|
import { toast } from '@/components/Toast';
|
||||||
import { extractErrorMessage } from '@/lib/func';
|
import { extractErrorMessage } from '@/lib/func';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
function TransactionsIndex() {
|
function TransactionsIndex() {
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ function TransactionsIndex() {
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="درباره باشگاه مشتریان"
|
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='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'>
|
<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>
|
||||||
|
|
||||||
{/* بخش نمایش اعتبار کاربر */}
|
{/* بخش نمایش اعتبار کاربر */}
|
||||||
<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'>
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
||||||
<h2 className='font-bold text-base dark:text-foreground'>اعتبار شما</h2>
|
<h2 className='font-bold text-base dark:text-foreground'>اعتبار شما</h2>
|
||||||
@@ -92,7 +93,7 @@ function TransactionsIndex() {
|
|||||||
</section>
|
</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'>
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
|
||||||
<h2 className='font-bold text-base dark:text-foreground'>تبدیل امتیاز به اعتبار</h2>
|
<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 { useGetMyCoupons } from '../hooks/useTransactionData';
|
||||||
import { toast } from '@/components/Toast';
|
import { toast } from '@/components/Toast';
|
||||||
import type { Coupon } from '../types/Types';
|
import type { Coupon } from '../types/Types';
|
||||||
|
import { glassSurfaceCard, glassSurfaceCardFlat } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
function TransactionsIndex() {
|
function TransactionsIndex() {
|
||||||
const { data: myCoupons, isLoading } = useGetMyCoupons();
|
const { data: myCoupons, isLoading } = useGetMyCoupons();
|
||||||
@@ -32,7 +33,7 @@ function TransactionsIndex() {
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="درباره کدهای تخفیف"
|
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='col-span-3 py-9.5 pe-7 ps-5 relative'>
|
||||||
<div className='w-full h-full absolute top-0 left-0 overflow-clip'>
|
<div className='w-full h-full absolute top-0 left-0 overflow-clip'>
|
||||||
@@ -94,7 +95,7 @@ function TransactionsIndex() {
|
|||||||
{coupons.map((coupon) => (
|
{coupons.map((coupon) => (
|
||||||
<article
|
<article
|
||||||
key={coupon.id}
|
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'>
|
<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>
|
<h3 className='font-bold text-sm2'>مبلغ تخفیف: {formatDiscount(coupon)}</h3>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from 'react'
|
|||||||
import { useGetTransactions } from './hooks/useTransactionData';
|
import { useGetTransactions } from './hooks/useTransactionData';
|
||||||
import { TransactionsTable } from './components/TransactionsTable';
|
import { TransactionsTable } from './components/TransactionsTable';
|
||||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay';
|
import LoadingOverlay from '@/components/overlays/LoadingOverlay';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
function TransactionsReviewIndex() {
|
function TransactionsReviewIndex() {
|
||||||
const { data: transactionsResponse, isLoading: isLoadingTransactions } = useGetTransactions();
|
const { data: transactionsResponse, isLoading: isLoadingTransactions } = useGetTransactions();
|
||||||
@@ -56,7 +57,7 @@ function TransactionsReviewIndex() {
|
|||||||
</div>
|
</div>
|
||||||
</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} />
|
<TransactionsTable transactions={transactions} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -104,3 +104,17 @@ export interface NotificationsCountData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationsCountResponse = BaseResponse<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();
|
e.preventDefault();
|
||||||
onChangePosition();
|
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}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<Location color='#6b7280' variant='Bold' className='size-4' />
|
<Location color='#6b7280' variant='Bold' className='size-4' />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import InputField from '@/components/input/InputField';
|
import InputField from "@/components/input/InputField";
|
||||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
import { useEffect } from "react";
|
||||||
import { useGetProfile } from '../../../hooks/userProfileData';
|
import { useGetProfile } from "../../../hooks/userProfileData";
|
||||||
import { useEffect } from 'react';
|
import { NominatimReverseGeocodingResponse } from "../../types/Types";
|
||||||
|
|
||||||
interface AddressFormProps {
|
interface AddressFormProps {
|
||||||
formData: {
|
formData: {
|
||||||
@@ -12,79 +12,69 @@ interface AddressFormProps {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||||
onFormDataChange: (data: Partial<AddressFormProps['formData']>) => void;
|
onFormDataChange: (data: Partial<AddressFormProps["formData"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddressForm = ({
|
export const AddressForm = ({ formData, selectedAddress, onFormDataChange }: AddressFormProps) => {
|
||||||
formData,
|
const { data: profileData } = useGetProfile();
|
||||||
selectedAddress,
|
|
||||||
onFormDataChange,
|
|
||||||
}: AddressFormProps) => {
|
|
||||||
|
|
||||||
const { data: profileData } = useGetProfile()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profileData?.data?.phone && !formData.phone) {
|
if (profileData?.data?.phone && !formData.phone) {
|
||||||
onFormDataChange({ phone: '0' + profileData.data.phone });
|
onFormDataChange({ phone: "0" + profileData.data.phone });
|
||||||
}
|
}
|
||||||
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='px-4'>
|
<div className="px-4">
|
||||||
<InputField
|
<InputField
|
||||||
htmlFor={'addressTitle'}
|
htmlFor={"addressTitle"}
|
||||||
labelText={'عنوان آدرس'}
|
labelText={"عنوان آدرس"}
|
||||||
placeholder='مثال: منزل، محل کار ...'
|
placeholder="مثال: منزل، محل کار ..."
|
||||||
className='bg-inherit'
|
className="bg-inherit"
|
||||||
inputClassName='text-xs!'
|
inputClassName="text-xs!"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
htmlFor={'addressDetails'}
|
htmlFor={"addressDetails"}
|
||||||
labelText={'جزئیات آدرس'}
|
labelText={"جزئیات آدرس"}
|
||||||
placeholder='میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890'
|
placeholder="میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890"
|
||||||
className='bg-inherit mt-8'
|
className="bg-inherit mt-8"
|
||||||
value={formData.addressDetails}
|
value={formData.addressDetails}
|
||||||
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
||||||
inputClassName='text-xs!'
|
inputClassName="text-xs!"
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
htmlFor={'phone'}
|
htmlFor={"phone"}
|
||||||
labelText={'شماره تلفن'}
|
labelText={"شماره تلفن"}
|
||||||
placeholder='09123456789'
|
placeholder="09123456789"
|
||||||
className='bg-inherit mt-8'
|
className="bg-inherit mt-8"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
||||||
inputClassName='text-xs!'
|
inputClassName="text-xs!"
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
htmlFor={'postalCode'}
|
htmlFor={"postalCode"}
|
||||||
labelText={'کد پستی'}
|
labelText={"کد پستی"}
|
||||||
placeholder={selectedAddress?.address?.postcode || '1234567890'}
|
placeholder={selectedAddress?.address?.postcode || "1234567890"}
|
||||||
className='bg-inherit mt-8'
|
className="bg-inherit mt-8"
|
||||||
value={formData.postalCode || selectedAddress?.address?.postcode || ''}
|
value={formData.postalCode || selectedAddress?.address?.postcode || ""}
|
||||||
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
||||||
inputClassName='text-xs!'
|
inputClassName="text-xs!"
|
||||||
/>
|
/>
|
||||||
<div className='inline-flex justify-between items-center w-full mt-8'>
|
<div className="inline-flex justify-between items-center w-full mt-8">
|
||||||
<label
|
<label htmlFor={"isDefault"} className="text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer">
|
||||||
htmlFor={'isDefault'}
|
|
||||||
className='text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer'
|
|
||||||
>
|
|
||||||
تنظیم به عنوان آدرس پیشفرض
|
تنظیم به عنوان آدرس پیشفرض
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name={'isDefault'}
|
name={"isDefault"}
|
||||||
id={'isDefault'}
|
id={"isDefault"}
|
||||||
className='h-4.5 w-4.5 checked:accent-primary cursor-pointer'
|
className="h-4.5 w-4.5 checked:accent-primary cursor-pointer"
|
||||||
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
||||||
type='checkbox'
|
type="checkbox"
|
||||||
checked={formData.isDefault}
|
checked={formData.isDefault}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { toast } from "@/components/Toast";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCreateAddress, useUpdateAddress } from '../../hooks/useAddressData';
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from '@/components/Toast';
|
import { useCreateAddress, useUpdateAddress } from "../../hooks/useAddressData";
|
||||||
import { CreateAddressType, UpdateAddressType, NominatimReverseGeocodingResponse, Address } from '../../types/Types';
|
import { Address, CreateAddressType, NominatimReverseGeocodingResponse, UpdateAddressType } from "../../types/Types";
|
||||||
|
|
||||||
interface UseAddressFormProps {
|
interface UseAddressFormProps {
|
||||||
selectedPosition: [number, number] | null;
|
selectedPosition: [number, number] | null;
|
||||||
@@ -13,36 +13,29 @@ interface UseAddressFormProps {
|
|||||||
addressData?: Address | null;
|
addressData?: Address | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAddressForm = ({
|
export const useAddressForm = ({ selectedPosition, selectedAddress, fallbackPosition = [0, 0], initialPhone = "", editMode = false, addressData = null }: UseAddressFormProps) => {
|
||||||
selectedPosition,
|
|
||||||
selectedAddress,
|
|
||||||
fallbackPosition = [0, 0],
|
|
||||||
initialPhone = '',
|
|
||||||
editMode = false,
|
|
||||||
addressData = null,
|
|
||||||
}: UseAddressFormProps) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect');
|
const redirect = searchParams.get("redirect");
|
||||||
const { mutate: createAddress, isPending: isCreating } = useCreateAddress();
|
const { mutate: createAddress, isPending: isCreating } = useCreateAddress();
|
||||||
const { mutate: updateAddress, isPending: isUpdating } = useUpdateAddress();
|
const { mutate: updateAddress, isPending: isUpdating } = useUpdateAddress();
|
||||||
const isPending = isCreating || isUpdating;
|
const isPending = isCreating || isUpdating;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: "",
|
||||||
addressDetails: '',
|
addressDetails: "",
|
||||||
phone: initialPhone,
|
phone: initialPhone,
|
||||||
postalCode: '',
|
postalCode: "",
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editMode && addressData) {
|
if (editMode && addressData) {
|
||||||
setFormData({
|
setFormData({
|
||||||
title: addressData.title || '',
|
title: addressData.title || "",
|
||||||
addressDetails: addressData.address || '',
|
addressDetails: addressData.address || "",
|
||||||
phone: addressData.phone || initialPhone,
|
phone: addressData.phone || initialPhone,
|
||||||
postalCode: addressData.postalCode || '',
|
postalCode: addressData.postalCode || "",
|
||||||
isDefault: addressData.isDefault || false,
|
isDefault: addressData.isDefault || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,26 +51,24 @@ export const useAddressForm = ({
|
|||||||
const positionToUse: [number, number] = selectedPosition || fallbackPosition || [0, 0];
|
const positionToUse: [number, number] = selectedPosition || fallbackPosition || [0, 0];
|
||||||
|
|
||||||
if (!formData.title.trim()) {
|
if (!formData.title.trim()) {
|
||||||
toast('لطفا عنوان آدرس را وارد کنید', 'error');
|
toast("لطفا عنوان آدرس را وارد کنید", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.addressDetails.trim()) {
|
if (!formData.addressDetails.trim()) {
|
||||||
toast('لطفا جزئیات آدرس را وارد کنید', 'error');
|
toast("لطفا جزئیات آدرس را وارد کنید", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.phone.trim()) {
|
if (!formData.phone.trim()) {
|
||||||
toast('لطفا شماره تلفن را وارد کنید', 'error');
|
toast("لطفا شماره تلفن را وارد کنید", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseAddressData = {
|
const baseAddressData = {
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
address: formData.addressDetails,
|
address: formData.addressDetails,
|
||||||
city: selectedAddress?.address?.city || selectedAddress?.address?.district || addressData?.city || '',
|
postalCode: formData.postalCode || selectedAddress?.address?.postcode || addressData?.postalCode || "",
|
||||||
province: selectedAddress?.address?.province || selectedAddress?.address?.state || addressData?.province || '',
|
|
||||||
postalCode: formData.postalCode || selectedAddress?.address?.postcode || addressData?.postalCode || '',
|
|
||||||
latitude: positionToUse[0],
|
latitude: positionToUse[0],
|
||||||
longitude: positionToUse[1],
|
longitude: positionToUse[1],
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
@@ -92,7 +83,7 @@ export const useAddressForm = ({
|
|||||||
|
|
||||||
updateAddress(updateData, {
|
updateAddress(updateData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast('آدرس با موفقیت بهروزرسانی شد', 'success');
|
toast("آدرس با موفقیت بهروزرسانی شد", "success");
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.replace(redirect);
|
router.replace(redirect);
|
||||||
} else {
|
} else {
|
||||||
@@ -100,8 +91,8 @@ export const useAddressForm = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'خطا در بهروزرسانی آدرس';
|
const errorMessage = error instanceof Error ? error.message : "خطا در بهروزرسانی آدرس";
|
||||||
toast(errorMessage, 'error');
|
toast(errorMessage, "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -109,7 +100,7 @@ export const useAddressForm = ({
|
|||||||
|
|
||||||
createAddress(createData, {
|
createAddress(createData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast('آدرس با موفقیت ثبت شد', 'success');
|
toast("آدرس با موفقیت ثبت شد", "success");
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.replace(redirect);
|
router.replace(redirect);
|
||||||
} else {
|
} else {
|
||||||
@@ -117,8 +108,8 @@ export const useAddressForm = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'خطا در ثبت آدرس';
|
const errorMessage = error instanceof Error ? error.message : "خطا در ثبت آدرس";
|
||||||
toast(errorMessage, 'error');
|
toast(errorMessage, "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -131,4 +122,3 @@ export const useAddressForm = ({
|
|||||||
submitAddress,
|
submitAddress,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||||
|
|
||||||
export const formatAddress = (address: Address): string => {
|
export const formatAddress = (address: Address): string => {
|
||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return address.address;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
||||||
@@ -11,8 +11,6 @@ export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocoding
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
selectedAddress.address.state ?? selectedAddress.address.province,
|
|
||||||
selectedAddress.address.city,
|
|
||||||
selectedAddress.address.neighbourhood,
|
selectedAddress.address.neighbourhood,
|
||||||
!selectedAddress.address.road.startsWith('میدان') &&
|
!selectedAddress.address.road.startsWith('میدان') &&
|
||||||
!selectedAddress.address.road.startsWith('بلوار') &&
|
!selectedAddress.address.road.startsWith('بلوار') &&
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Address } from './types/Types';
|
|||||||
import Prompt from '@/components/utils/Prompt';
|
import Prompt from '@/components/utils/Prompt';
|
||||||
import { toast } from '@/components/Toast';
|
import { toast } from '@/components/Toast';
|
||||||
import useToggle from '@/hooks/helpers/useToggle';
|
import useToggle from '@/hooks/helpers/useToggle';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ function UserAddressesPage({ }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatAddress = (address: Address) => {
|
const formatAddress = (address: Address) => {
|
||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return address.address;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEditHref = (addressId: string) => {
|
const getEditHref = (addressId: string) => {
|
||||||
@@ -107,7 +108,7 @@ function UserAddressesPage({ }: Props) {
|
|||||||
addresses.map((address) => (
|
addresses.map((address) => (
|
||||||
<div
|
<div
|
||||||
key={address.id}
|
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">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<h2 className='text-sm font-medium'>{address.title}</h2>
|
<h2 className='text-sm font-medium'>{address.title}</h2>
|
||||||
@@ -132,7 +133,7 @@ function UserAddressesPage({ }: Props) {
|
|||||||
onClick={() => handleSetDefault(address.id)}
|
onClick={() => handleSetDefault(address.id)}
|
||||||
disabled={isSettingDefault}
|
disabled={isSettingDefault}
|
||||||
pending={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' />
|
<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">
|
<div className="flex gap-2">
|
||||||
<Link href={getEditHref(address.id)}>
|
<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' />
|
<Edit2 className='stroke-foreground size-5 mb-0.5' />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDeleteClick(address.id)}
|
onClick={() => handleDeleteClick(address.id)}
|
||||||
disabled={isDeleting}
|
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' />
|
<Trash className='stroke-foreground size-5 mb-0.5' />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export interface Address {
|
|||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
|
||||||
province: string;
|
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -25,8 +23,6 @@ export type AddressResponse = BaseResponse<Address>;
|
|||||||
export type CreateAddressType = {
|
export type CreateAddressType = {
|
||||||
title: string;
|
title: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
|
||||||
province: string;
|
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Calendar from '@/components/ui/calendar';
|
|||||||
import { getDateLib } from "react-day-picker/persian";
|
import { getDateLib } from "react-day-picker/persian";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useGetProfile, useSingleUpload, useUpdateProfile } from '../hooks/userProfileData';
|
import { useGetProfile, useSingleUpload, useUpdateProfile } from '../hooks/userProfileData';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { UpdateProfileType } from '../types/Types';
|
import { UpdateProfileType } from '../types/Types';
|
||||||
@@ -197,7 +198,7 @@ function ProfileIndex({ }: Props) {
|
|||||||
<h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1>
|
<h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1>
|
||||||
</div>
|
</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="bg-inherit">
|
||||||
<div className="justify-self-center w-fit relative text-center pb-10">
|
<div className="justify-self-center w-fit relative text-center pb-10">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useGetProfile } from './hooks/userProfileData';
|
import { useGetProfile } from './hooks/userProfileData';
|
||||||
|
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
|
||||||
|
|
||||||
type Props = object
|
type Props = object
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ function ProfileIndex({ }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="">
|
||||||
<div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border">
|
<div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border">
|
||||||
|
|||||||
@@ -1,59 +1,49 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Button from '@/components/button/PrimaryButton';
|
import Button from "@/components/button/PrimaryButton";
|
||||||
import PasswordField from '@/components/input/PasswordField';
|
import PasswordField from "@/components/input/PasswordField";
|
||||||
import TabContainer from '@/components/tab/TabContainer'
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { TabHeader } from '@/components/tab/TabHeader'
|
import Accordion from "@/components/utils/Accordion";
|
||||||
import { Switch } from '@/components/ui/switch';
|
import Seperator from "@/components/utils/Seperator";
|
||||||
import Accordion from '@/components/utils/Accordion';
|
import { SmartIcon } from "@/components/utils/SmartIcon";
|
||||||
import Seperator from '@/components/utils/Seperator';
|
import { getI18nObject } from "@/lib/i18n";
|
||||||
import { SmartIcon } from '@/components/utils/SmartIcon';
|
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||||
import { getI18nObject } from '@/lib/i18n';
|
import { SettingsData } from "@/types/i18n/settings";
|
||||||
import { SettingsData } from '@/types/i18n/settings';
|
import { ArrowLeft, Key, TickCircle } from "iconsax-react";
|
||||||
import { Key, KeySquare, Notification1, ArrowLeft, TickCircle } from 'iconsax-react'
|
import { useRouter } from "next/navigation";
|
||||||
import { useRouter } from 'next/navigation';
|
import React, { Fragment, useState } from "react";
|
||||||
import React, { Fragment, useState } from 'react'
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
function UserSettingsIndex() {
|
function UserSettingsIndex() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation("settings");
|
||||||
const data: SettingsData = getI18nObject(t, "data");
|
const data: SettingsData = getI18nObject(t, "data");
|
||||||
const [statisticalParticipation, setStatisticalParticipation] = useState(true);
|
const [statisticalParticipation, setStatisticalParticipation] = useState(true);
|
||||||
|
|
||||||
const onSwitched = (e: React.MouseEvent) => {
|
const onSwitched = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
console.log('Save changed setting?', target.id);
|
console.log("Save changed setting?", target.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleStatisticalToggle = (checked: boolean) => {
|
const handleStatisticalToggle = (checked: boolean) => {
|
||||||
setStatisticalParticipation(checked);
|
setStatisticalParticipation(checked);
|
||||||
console.log('Statistical participation:', checked);
|
console.log("Statistical participation:", checked);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const notifsTab = () => {
|
const notifsTab = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="about-title" className='py-4 flex-1 h-full'>
|
<section aria-labelledby="about-title" className="py-4 flex-1 h-full">
|
||||||
<section
|
<section className={glassSurfaceCard("p-4")}>
|
||||||
className="bg-container rounded-container shadow-container p-4">
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-[25px]">
|
<div className="flex justify-between items-center pb-[25px]">
|
||||||
<h2 className='text-sm leading-5'>{data.TabNotifications.Heading}</h2>
|
<h2 className="text-sm leading-5">{data.TabNotifications.Heading}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='p-2 pt-0'>
|
<div className="p-2 pt-0">
|
||||||
{data.TabNotifications.Accordions.map((accordion, i) => {
|
{data.TabNotifications.Accordions.map((accordion, i) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<Accordion
|
<Accordion title={accordion.Heading} group={"accordions"} icon={accordion.Icon}>
|
||||||
title={accordion.Heading}
|
<div className="grid gap-4 mb-3">
|
||||||
group={'accordions'}
|
|
||||||
icon={accordion.Icon}
|
|
||||||
>
|
|
||||||
<div className='grid gap-4 mb-3'>
|
|
||||||
{accordion.Items.map((item, j) => {
|
{accordion.Items.map((item, j) => {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
@@ -61,184 +51,136 @@ function UserSettingsIndex() {
|
|||||||
htmlFor={item.HtmlName}
|
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"
|
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">
|
<span className="inline-flex items-center gap-2.5 text-sm2">
|
||||||
<SmartIcon icon={item.Icon} />
|
<SmartIcon icon={item.Icon} />
|
||||||
{item.Label}
|
{item.Label}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch onClick={onSwitched} id={item.HtmlName} name={item.HtmlName} className="w-12 h-6 scale-[0.9]" />
|
||||||
onClick={onSwitched}
|
|
||||||
id={item.HtmlName}
|
|
||||||
name={item.HtmlName}
|
|
||||||
className="w-12 h-6 scale-[0.9]"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Seperator className='last:hidden' />
|
<Seperator className="last:hidden" />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const passwordTab = () => {
|
const passwordTab = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="reviews-title" className='py-4'>
|
<section aria-labelledby="reviews-title" className="py-4">
|
||||||
<section
|
<section className={glassSurfaceCard("p-4")}>
|
||||||
className="bg-container rounded-container shadow-container p-4">
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-[25px]">
|
<div className="flex justify-between items-center pb-[25px]">
|
||||||
<h2 className='text-sm leading-5'>{data.TabPassword.Heading}</h2>
|
<h2 className="text-sm leading-5">{data.TabPassword.Heading}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form onSubmit={(e) => e.preventDefault()} className="grid gap-6 p-2">
|
||||||
onSubmit={(e) => e.preventDefault()}
|
|
||||||
className='grid gap-6 p-2'
|
|
||||||
>
|
|
||||||
{data.TabPassword.Inputs.map((input, i) => {
|
{data.TabPassword.Inputs.map((input, i) => {
|
||||||
return (
|
return <PasswordField labelText={input.Label} className="bg-container" key={i} htmlFor={input.HtmlName ?? ""} placeholder={input.Placeholder} autoComplete={input.AutoComplete} />;
|
||||||
<PasswordField
|
})}
|
||||||
labelText={input.Label}
|
|
||||||
className='bg-container'
|
|
||||||
key={i}
|
|
||||||
htmlFor={input.HtmlName ?? ''}
|
|
||||||
placeholder={input.Placeholder}
|
|
||||||
autoComplete={input.AutoComplete}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
{data.TabPassword.Rules.map((rule, i) => {
|
{data.TabPassword.Rules.map((rule, i) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<section key={i} className="bg-background rounded-normal p-4 text-xs flex flex-col gap-2 font-light">
|
||||||
key={i}
|
<h6 className="text-sm mb-2">{rule.Heading}</h6>
|
||||||
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) => {
|
{rule.Items.map((item, i) => {
|
||||||
return (
|
return <li key={i}>{item}</li>;
|
||||||
<li key={i}>{item}</li>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Button className='mt-4'>
|
<Button className="mt-4">
|
||||||
<div className='flex items-center justify-center gap-1'>
|
<div className="flex items-center justify-center gap-1">
|
||||||
<TickCircle className='stroke-white' size={20} />
|
<TickCircle className="stroke-white" size={20} />
|
||||||
<span className='mt-0.5'>
|
<span className="mt-0.5">{data.TabPassword.ButtonSubmit}</span>
|
||||||
{data.TabPassword.ButtonSubmit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const authenticatorTab = () => {
|
const authenticatorTab = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="reviews-title" className='py-4'>
|
<section aria-labelledby="reviews-title" className="py-4">
|
||||||
<div
|
<div className={glassSurfaceCard("p-4")}>
|
||||||
className="bg-container rounded-container shadow-container p-4">
|
|
||||||
|
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
<h2 className='text-sm leading-5'>{data.TabAuthenticator.Heading}</h2>
|
<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>
|
<p className="text-xs mt-3 text-disabled-text font-light">{data.TabAuthenticator.Description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Seperator />
|
<Seperator />
|
||||||
|
|
||||||
<div className='p-2'>
|
<div className="p-2">
|
||||||
<div className='mt-14 bg-blue-100 p-4 place-self-center rounded-full'>
|
<div className="mt-14 bg-blue-100 p-4 place-self-center rounded-full">
|
||||||
<Key size={32} className='stroke-blue-500' />
|
<Key size={32} className="stroke-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm2 mt-4 font-light place-self-center'>
|
<p className="text-sm2 mt-4 font-light place-self-center">{data.TabAuthenticator.Tip}</p>
|
||||||
{data.TabAuthenticator.Tip}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className='place-self-center mt-2'>
|
<div className="place-self-center mt-2">
|
||||||
<Button className='mt-4 px-14 py-2'>
|
<Button className="mt-4 px-14 py-2">
|
||||||
<div className='flex items-center justify-center gap-1'>
|
<div className="flex items-center justify-center gap-1">
|
||||||
<TickCircle className='stroke-white' size={20} />
|
<TickCircle className="stroke-white" size={20} />
|
||||||
<span className='mt-0.5'>
|
<span className="mt-0.5">{data.TabAuthenticator.ButtonSubmit}</span>
|
||||||
{data.TabAuthenticator.ButtonSubmit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.TabAuthenticator.Guides.map((guide, i) => {
|
{data.TabAuthenticator.Guides.map((guide, i) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<section key={i} className="bg-background rounded-normal mt-14 p-4 text-xs flex flex-col gap-2 font-light">
|
||||||
key={i}
|
<h6 className="text-sm">{guide.Heading}</h6>
|
||||||
className='bg-background rounded-normal mt-14 p-4 text-xs flex flex-col gap-2 font-light'
|
<p className="text-disabled-text">{guide.Description}</p>
|
||||||
>
|
|
||||||
<h6 className='text-sm'>{guide.Heading}</h6>
|
|
||||||
<p className='text-disabled-text'>{guide.Description}</p>
|
|
||||||
{guide.Items.map((item, i) => {
|
{guide.Items.map((item, i) => {
|
||||||
return (
|
return (
|
||||||
<div key={i}>
|
<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'>
|
<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>
|
||||||
{item.Mark}
|
<span>{item.Label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
);
|
||||||
{item.Label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{guide.Notes.map((note, i) => {
|
{guide.Notes.map((note, i) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i} className="mt-4 p-3 text-yellow-800 rounded-lg bg-yellow-50 border border-solid border-yellow-200">
|
||||||
key={i}
|
|
||||||
className='mt-4 p-3 text-yellow-800 rounded-lg bg-yellow-50 border border-solid border-yellow-200'
|
|
||||||
>
|
|
||||||
{note}
|
{note}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col gap-6'>
|
<div className="overflow-y-auto h-full noscrollbar flex flex-col gap-6">
|
||||||
<div className='grid grid-cols-3 items-center'>
|
<div className="grid grid-cols-3 items-center">
|
||||||
<span></span>
|
<span></span>
|
||||||
<h1 className='text-sm2 place-self-center font-medium'>{data.Heading}</h1>
|
<h1 className="text-sm2 place-self-center font-medium">{data.Heading}</h1>
|
||||||
<ArrowLeft
|
<ArrowLeft
|
||||||
className='cursor-pointer place-self-end'
|
className="cursor-pointer place-self-end"
|
||||||
size='24'
|
size="24"
|
||||||
color='currentColor'
|
color="currentColor"
|
||||||
onClick={() => { router.back() }}
|
onClick={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className='bg-container rounded-container shadow-container p-4'>
|
<section className={glassSurfaceCard("p-4")}>
|
||||||
<div className='flex justify-between items-center pb-4'>
|
<div className="flex justify-between items-center pb-4">
|
||||||
<h2 className='text-sm leading-5'>{data.StatisticalParticipation.Heading}</h2>
|
<h2 className="text-sm leading-5">{data.StatisticalParticipation.Heading}</h2>
|
||||||
<Switch
|
<Switch
|
||||||
checked={statisticalParticipation}
|
checked={statisticalParticipation}
|
||||||
onCheckedChange={handleStatisticalToggle}
|
onCheckedChange={handleStatisticalToggle}
|
||||||
@@ -247,13 +189,10 @@ function UserSettingsIndex() {
|
|||||||
className="w-12 h-6 scale-[0.9]"
|
className="w-12 h-6 scale-[0.9]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-xs text-disabled-text font-light'>
|
<p className="text-xs text-disabled-text font-light">{data.StatisticalParticipation.Description}</p>
|
||||||
{data.StatisticalParticipation.Description}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserSettingsIndex
|
export default UserSettingsIndex;
|
||||||
|
|||||||
@@ -1,111 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Button from '@/components/button/PrimaryButton';
|
import Button from "@/components/button/PrimaryButton";
|
||||||
import InputField from '@/components/input/InputField';
|
import InputField from "@/components/input/InputField";
|
||||||
import { ef, ue } from '@/lib/helpers/utfNumbers';
|
import { ef, ue } from "@/lib/helpers/utfNumbers";
|
||||||
import { ArrowLeft, ArrowRight, WalletMinus } from 'iconsax-react';
|
import { glassSurfaceCard } from "@/lib/styles/glassSurface";
|
||||||
import { useRouter } from 'next/navigation';
|
import { ArrowLeft, ArrowRight, WalletMinus } from "iconsax-react";
|
||||||
import React, { useState } from 'react'
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
type Props = object
|
type Props = object;
|
||||||
|
|
||||||
function UserWalletIndex({ }: Props) {
|
function UserWalletIndex({}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [chargeValue, setChargeValue] = useState<string>('0');
|
const [chargeValue, setChargeValue] = useState<string>("0");
|
||||||
const chargeAction = (e: React.FormEvent<HTMLFormElement>) => {
|
const chargeAction = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = new FormData(e.currentTarget)
|
const data = new FormData(e.currentTarget);
|
||||||
const value = Number(ue(String(data.get('chargeValue') ?? 0))) + 0;
|
const value = Number(ue(String(data.get("chargeValue") ?? 0))) + 0;
|
||||||
console.log("Charge for: ", value);
|
console.log("Charge for: ", value);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
<div className="overflow-y-auto h-full noscrollbar flex flex-col">
|
||||||
<div className='grid grid-cols-3 items-center'>
|
<div className="grid grid-cols-3 items-center">
|
||||||
<span></span>
|
<span></span>
|
||||||
<h1 className='text-sm2 place-self-center font-medium'>پروفایل کاربری</h1>
|
<h1 className="text-sm2 place-self-center font-medium">پروفایل کاربری</h1>
|
||||||
<ArrowLeft
|
<ArrowLeft
|
||||||
className='cursor-pointer place-self-end'
|
className="cursor-pointer place-self-end"
|
||||||
size='24'
|
size="24"
|
||||||
color='currentColor'
|
color="currentColor"
|
||||||
onClick={() => { router.back() }}
|
onClick={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form onSubmit={chargeAction} className={glassSurfaceCard("mt-8 flex-1 h-full w-full py-6 px-4 flex flex-col justify-between")}>
|
||||||
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="">
|
||||||
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
|
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
|
||||||
<div className='block'>
|
<div className="block">
|
||||||
<div className='font-medium text-sm flex items-center gap-2'>
|
<div className="font-medium text-sm flex items-center gap-2">
|
||||||
<WalletMinus className='size-5 stroke-foreground mb-0.5' />
|
<WalletMinus className="size-5 stroke-foreground mb-0.5" />
|
||||||
موجودی کیف پول
|
موجودی کیف پول
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-primary mt-3 inline-flex dark:text-neutral-200 items-center gap-2'>
|
<div className="text-sm text-primary mt-3 inline-flex dark:text-neutral-200 items-center gap-2">{ef("250.000 تومان")}</div>
|
||||||
{ef('250.000 تومان')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 mt-6">
|
<div className="grid gap-3 mt-6">
|
||||||
<div className='inline-flex items-center mb-3 gap-2.5 text-sm2'>
|
<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">
|
||||||
</div>
|
<ArrowRight className="stroke-foreground size-5" />
|
||||||
<Button
|
<span className="mt-0.5">{ef("50,000 تومان")}</span>
|
||||||
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>
|
||||||
<Button
|
<Button type="button" onClick={() => setChargeValue("150000")} className="bg-disabled! text-sm2 text-foreground! flex justify-between items-center">
|
||||||
type='button'
|
<ArrowRight className="stroke-foreground size-5" />
|
||||||
onClick={() => setChargeValue('150000')}
|
<span className="mt-0.5">{ef("150,000 تومان")}</span>
|
||||||
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>
|
||||||
<Button
|
<Button type="button" onClick={() => setChargeValue("300000")} className="bg-disabled! text-sm2 text-foreground! flex justify-between items-center">
|
||||||
type='button'
|
<ArrowRight className="stroke-foreground size-5" />
|
||||||
onClick={() => setChargeValue('300000')}
|
<span className="mt-0.5">{ef("300,000 تومان")}</span>
|
||||||
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>
|
</Button>
|
||||||
<InputField
|
<InputField
|
||||||
className='bg-container mt-7'
|
className="bg-container mt-7"
|
||||||
labelText='مبلغ دلخواه (تومان)'
|
labelText="مبلغ دلخواه (تومان)"
|
||||||
dir='ltr'
|
dir="ltr"
|
||||||
inputMode='decimal'
|
inputMode="decimal"
|
||||||
htmlFor='chargeValue'
|
htmlFor="chargeValue"
|
||||||
placeholder=''
|
placeholder=""
|
||||||
value={ef(chargeValue)}
|
value={ef(chargeValue)}
|
||||||
onChange={(e) => setChargeValue(e.target.value)}
|
onChange={(e) => setChargeValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full text-center mt-6'>
|
<div className="w-full text-center mt-6">
|
||||||
<Button
|
<Button type="submit">شارژ کیف پول</Button>
|
||||||
type='submit'
|
|
||||||
>
|
|
||||||
شارژ کیف پول
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserWalletIndex
|
export default UserWalletIndex;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ActiveChecker from "@/components/ActiveChecker";
|
import ActiveChecker from "@/components/ActiveChecker";
|
||||||
|
import AppBackground from "@/components/background/AppBackground";
|
||||||
import CartChecker from "@/components/CartChecker";
|
import CartChecker from "@/components/CartChecker";
|
||||||
import RestaurantHeadManager from "@/components/RestaurantHeadManager";
|
import RestaurantHeadManager from "@/components/RestaurantHeadManager";
|
||||||
import RestaurantNotFoundGuard from "@/components/RestaurantNotFoundGuard";
|
import RestaurantNotFoundGuard from "@/components/RestaurantNotFoundGuard";
|
||||||
import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper";
|
import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper";
|
||||||
|
import { PatternBackgroundProvider } from "@/components/background/PatternBackgroundProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -15,7 +17,12 @@ export default function RestaurantLayoutClient({ children }: Props) {
|
|||||||
<>
|
<>
|
||||||
<RestaurantHeadManager />
|
<RestaurantHeadManager />
|
||||||
<RestaurantNotFoundGuard>
|
<RestaurantNotFoundGuard>
|
||||||
<PreferenceWrapper>{children}</PreferenceWrapper>
|
<PatternBackgroundProvider>
|
||||||
|
<PreferenceWrapper>
|
||||||
|
<AppBackground />
|
||||||
|
{children}
|
||||||
|
</PreferenceWrapper>
|
||||||
|
</PatternBackgroundProvider>
|
||||||
</RestaurantNotFoundGuard>
|
</RestaurantNotFoundGuard>
|
||||||
<CartChecker />
|
<CartChecker />
|
||||||
<ActiveChecker />
|
<ActiveChecker />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const res = await fetchWithTimeout(url, {
|
const res = await fetchWithTimeout(url, {
|
||||||
timeoutMs: 5000,
|
timeoutMs: 15_000,
|
||||||
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
|
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchAboutResponse,
|
fetchAboutResponse,
|
||||||
fetchCategoriesResponse,
|
fetchCategoriesResponse,
|
||||||
fetchFoodsResponse,
|
fetchFoodsResponse,
|
||||||
|
fetchSlidersResponse,
|
||||||
} from "./serverMenuFetch";
|
} from "./serverMenuFetch";
|
||||||
|
|
||||||
export async function prefetchMenuPageData(
|
export async function prefetchMenuPageData(
|
||||||
@@ -22,5 +23,9 @@ export async function prefetchMenuPageData(
|
|||||||
queryKey: ["categories", name],
|
queryKey: ["categories", name],
|
||||||
queryFn: () => fetchCategoriesResponse(name),
|
queryFn: () => fetchCategoriesResponse(name),
|
||||||
}),
|
}),
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["sliders", name],
|
||||||
|
queryFn: () => fetchSlidersResponse(name),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { API_BASE_URL } from "@/config/const";
|
import { API_BASE_URL } from "@/config/const";
|
||||||
import type { AboutResponse } from "../(Main)/about/types/Types";
|
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";
|
import { getRestaurant, RESTAURANT_FETCH_TIMEOUT_MS } from "./getRestaurant";
|
||||||
|
|
||||||
function createServerAxios(slug: string) {
|
function createServerAxios(slug: string) {
|
||||||
@@ -37,3 +41,17 @@ export async function fetchCategoriesResponse(
|
|||||||
);
|
);
|
||||||
return data;
|
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 "tailwindcss";
|
||||||
@import "../../public/assets/css/fonts.css";
|
@import "../../public/assets/css/fonts.css";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "../styles/glass.css";
|
||||||
|
|
||||||
/* @custom-variant dark (&:is(.dark *)); */
|
/* @custom-variant dark (&:is(.dark *)); */
|
||||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
@@ -396,3 +397,37 @@ html[data-theme="dark"] {
|
|||||||
-webkit-mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
-webkit-mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
||||||
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 TranslationsProvider from "@/components/utils/TranslationsProdiver";
|
||||||
import ToastContainer from "@/components/Toast";
|
import ToastContainer from "@/components/Toast";
|
||||||
import { ThemeColorSetter } from "@/components/ThemeColorSetter";
|
import { ThemeColorSetter } from "@/components/ThemeColorSetter";
|
||||||
|
import { ThemeBootScript } from "@/components/ThemeBootScript";
|
||||||
import PwaServiceWorker from "@/components/PwaServiceWorker";
|
import PwaServiceWorker from "@/components/PwaServiceWorker";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -48,6 +49,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
className={`antialiased bg-background h-svh overflow-hidden`}
|
className={`antialiased bg-background h-svh overflow-hidden`}
|
||||||
>
|
>
|
||||||
|
<ThemeBootScript />
|
||||||
<ReactQueryProvider>
|
<ReactQueryProvider>
|
||||||
<TranslationsProvider
|
<TranslationsProvider
|
||||||
namespaces={i18nNamespaces}
|
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 () {
|
import RestaurantCard from "@/app/components/RestaurantCard";
|
||||||
return (
|
import RestaurantsSkeleton from "@/app/components/RestaurantsSkeleton";
|
||||||
<div>
|
import { useGetAllRestaurants } from "@/app/hooks/useAppData";
|
||||||
<Link href={'/auth'}>Auth</Link>
|
import { PublicRestaurant } from "@/app/types/Types";
|
||||||
<br />
|
import SearchBox from "@/components/input/SearchBox";
|
||||||
<Link href={'/zhivan'}>Menu</Link>
|
import Image from "next/image";
|
||||||
<br />
|
import { useMemo, useState } from "react";
|
||||||
<Link href={'/'}>Index</Link>
|
import "./home.css";
|
||||||
<br />
|
|
||||||
</div>
|
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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useLayoutEffect } from 'react'
|
||||||
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'
|
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'
|
||||||
|
import { getCachedMenuColor, getRestaurantSlugFromPath, getThemeCache, applyCachedThemeToDom } from '@/lib/helpers/themeCache'
|
||||||
|
|
||||||
export function ThemeColorSetter() {
|
export function ThemeColorSetter() {
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
try {
|
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) {
|
if (savedColor) {
|
||||||
const oklchColor = hexToOklch(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 { motion, Variants } from "framer-motion";
|
||||||
import { Icon, SearchNormal } from "iconsax-react";
|
import { Icon, SearchNormal } from "iconsax-react";
|
||||||
import { ChevronDown } from "lucide-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 {
|
export interface ComboboxOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +29,12 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>;
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
type DropdownPosition = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
function Combobox({
|
function Combobox({
|
||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
@@ -37,17 +44,53 @@ function Combobox({
|
|||||||
selectedId,
|
selectedId,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [expand, setExpand] = useState(expanded);
|
const [expand, setExpand] = useState(expanded);
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||||
const boxRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
const target = event.target as Node;
|
||||||
setExpand(false);
|
if (
|
||||||
|
boxRef.current?.contains(target) ||
|
||||||
|
dropdownRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setExpand(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (expand) {
|
if (expand) {
|
||||||
@@ -72,12 +115,14 @@ function Combobox({
|
|||||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||||
index: number,
|
index: number,
|
||||||
) => {
|
) => {
|
||||||
e.stopPropagation(); // prevent toggle
|
e.stopPropagation();
|
||||||
onSelectionChange(e, index);
|
onSelectionChange(e, index);
|
||||||
setExpand(false);
|
setExpand(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedOption = options.find((x) => x.id === selectedId);
|
const selectedOption = options.find((x) => x.id === selectedId);
|
||||||
|
const filteredOptions = options.filter((v) => v.title?.includes(searchValue));
|
||||||
|
|
||||||
const activeIcon =
|
const activeIcon =
|
||||||
selectedOption?.icon &&
|
selectedOption?.icon &&
|
||||||
React.createElement(selectedOption.icon, {
|
React.createElement(selectedOption.icon, {
|
||||||
@@ -93,7 +138,7 @@ function Combobox({
|
|||||||
collapse: {
|
collapse: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
scale: 0.95,
|
scale: 0.95,
|
||||||
pointerEvents: "none", // disable clicks
|
pointerEvents: "none",
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
ease: "easeInOut",
|
ease: "easeInOut",
|
||||||
@@ -102,7 +147,7 @@ function Combobox({
|
|||||||
expand: {
|
expand: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
pointerEvents: "auto", // re-enable clicks
|
pointerEvents: "auto",
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
ease: "easeInOut",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
className={clsx("relative", props.className ?? "")}
|
className={clsx("relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
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}
|
tabIndex={0}
|
||||||
onClick={toggleExpand}
|
onClick={toggleExpand}
|
||||||
@@ -163,59 +279,7 @@ function Combobox({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
{dropdown}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,38 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import clsx from "clsx";
|
||||||
import Tooltip from '../utils/Tooltip';
|
import React from "react";
|
||||||
import clsx from 'clsx';
|
import Tooltip from "../utils/Tooltip";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
htmlFor: string;
|
htmlFor: string;
|
||||||
labelText?: React.ReactNode;
|
labelText?: React.ReactNode;
|
||||||
value?: string;
|
value?: string;
|
||||||
valid?: boolean
|
valid?: boolean;
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "id">;
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "id">;
|
||||||
|
|
||||||
|
const InputField = React.forwardRef<HTMLInputElement, Props>(({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => {
|
||||||
const InputField = React.forwardRef<HTMLInputElement, Props>(
|
|
||||||
({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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`}>
|
||||||
spellCheck={false}
|
<label className="absolute start-2 -top-2.5 px-2 text-[12px] text-foreground bg-container" htmlFor={htmlFor}>
|
||||||
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}
|
{labelText}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Tooltip content={inputProps['aria-errormessage']} hidden={!inputProps['aria-errormessage']}>
|
<Tooltip content={inputProps["aria-errormessage"]} hidden={!inputProps["aria-errormessage"]}>
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={clsx(
|
className={clsx(inputProps["aria-errormessage"] && "text-red-300!", "py-2.5 pt-3.5 text-sm2 w-full outline-0 leading-6!", inputClassName)}
|
||||||
inputProps['aria-errormessage'] && 'text-red-300!',
|
|
||||||
'py-2.5 pt-3.5 text-sm2 w-full outline-0 leading-6!',
|
|
||||||
inputClassName
|
|
||||||
)}
|
|
||||||
name={htmlFor}
|
name={htmlFor}
|
||||||
{...inputProps} />
|
{...inputProps}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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 { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||||
import SearchIcon from '../icons/SearchIcon'
|
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
|
||||||
import clsx from 'clsx'
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
import SearchIcon from "../icons/SearchIcon";
|
||||||
|
|
||||||
interface SearchboxProps
|
interface SearchboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "results"> {
|
||||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'results'> {
|
placeholder?: string;
|
||||||
placeholder?: string
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchBox({
|
export default function SearchBox({ className, value, placeholder = "", onChange, ...props }: SearchboxProps) {
|
||||||
className,
|
const { name } = useParams<{ name?: string }>();
|
||||||
value,
|
const { data: about } = useGetAbout();
|
||||||
placeholder = '',
|
const showAiLink = !!name && !!about?.data?.enableAiChat;
|
||||||
onChange,
|
|
||||||
...props
|
|
||||||
}: SearchboxProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex gap-4 items-center">
|
||||||
className={clsx(
|
<div className={glassSurfaceFlat("inline-flex rounded-xl px-4 h-10 w-full items-center content-center", className)}>
|
||||||
'bg-container inline-flex rounded-xl px-4 h-10 w-full items-center content-center',
|
<SearchIcon stroke="#8C90A3" />
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SearchIcon stroke='#8C90A3' />
|
|
||||||
<input
|
<input
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
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!'
|
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'
|
type="search"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -8,10 +9,10 @@ type Props = {
|
|||||||
|
|
||||||
function CategoryItemRenderer({ children, ...rest }: Props) {
|
function CategoryItemRenderer({ children, ...rest }: Props) {
|
||||||
return (
|
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
|
<div
|
||||||
{...rest}
|
{...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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { glassSurface } from '@/lib/styles/glassSurface';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -8,10 +9,10 @@ type Props = {
|
|||||||
|
|
||||||
function CategorySmallItemRenderer({ children, ...rest }: Props) {
|
function CategorySmallItemRenderer({ children, ...rest }: Props) {
|
||||||
return (
|
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
|
<div
|
||||||
{...rest}
|
{...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}
|
{children}
|
||||||
</div>
|
</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 type { Food, FoodImage } from "@/app/[name]/(Main)/types/Types";
|
||||||
import MinusIcon from "@/components/icons/MinusIcon";
|
import MinusIcon from "@/components/icons/MinusIcon";
|
||||||
import PlusIcon from "@/components/icons/PlusIcon";
|
import PlusIcon from "@/components/icons/PlusIcon";
|
||||||
import clsx from "clsx";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -52,9 +51,7 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
|||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content
|
return content.filter((item) => item && typeof item === "string").join("، ");
|
||||||
.filter((item) => item && typeof item === "string")
|
|
||||||
.join("، ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@@ -75,15 +72,9 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
|||||||
return basePrice;
|
return basePrice;
|
||||||
}, [food.price, food.discount]);
|
}, [food.price, food.discount]);
|
||||||
|
|
||||||
const formattedPrice = useMemo(
|
const formattedPrice = useMemo(() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"), [finalPrice]);
|
||||||
() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"),
|
|
||||||
[finalPrice],
|
|
||||||
);
|
|
||||||
|
|
||||||
const formattedOriginalPrice = useMemo(
|
const formattedOriginalPrice = useMemo(() => (food.price ? food.price.toLocaleString("fa-IR") : "0"), [food.price]);
|
||||||
() => (food.price ? food.price.toLocaleString("fa-IR") : "0"),
|
|
||||||
[food.price],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasDiscount = useMemo(() => (food.discount || 0) > 0, [food.discount]);
|
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}`;
|
const foodDetailUrl = `/${name}/${food.id}?category=${food.category}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex gap-4 w-full h-full items-center">
|
||||||
className={clsx(
|
|
||||||
"flex gap-4 w-full h-full items-center",
|
|
||||||
!isAvailable && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link href={foodDetailUrl} className="cursor-pointer">
|
<Link href={foodDetailUrl} className="cursor-pointer">
|
||||||
<img
|
<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)} />
|
||||||
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>
|
</Link>
|
||||||
<div className="w-full inline-flex flex-col justify-between min-w-0">
|
<div className="w-full inline-flex flex-col justify-between min-w-0">
|
||||||
<Link href={foodDetailUrl} className="cursor-pointer min-w-0">
|
<Link href={foodDetailUrl} className="cursor-pointer min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word">
|
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word">{foodName}</div>
|
||||||
{foodName}
|
{(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>
|
|
||||||
{(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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="inline-flex mt-2 gap-2 justify-between w-full items-center">
|
<div className="inline-flex mt-2 gap-2 justify-between w-full items-start">
|
||||||
<div className="w-full flex flex-col gap-1" dir="ltr">
|
<div className="w-full flex flex-col gap-1 items-end" dir="ltr">
|
||||||
{hasDiscount ? (
|
{hasDiscount ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-disabled-text line-through">
|
<span className="text-xs text-disabled-text line-through">{formattedOriginalPrice} T</span>
|
||||||
{formattedOriginalPrice} T
|
<span className="text-sm font-medium text-black dark:text-white">{formattedPrice} T</span>
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-primary dark:text-foreground">
|
|
||||||
{formattedPrice} T
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm mt-1 text-right">
|
<span className="text-sm text-right">{formattedPrice} T</span>
|
||||||
{formattedPrice} T
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={isAvailable ? { scale: 1.05 } : undefined}
|
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 ? (
|
{quantity <= 0 ? (
|
||||||
<button
|
<button onClick={handleAddToCart} disabled={!isAvailable} className="inline-flex w-full justify-center items-center gap-2 disabled:pointer-events-none">
|
||||||
onClick={handleAddToCart}
|
{isAvailable && <PlusIcon />}
|
||||||
disabled={!isAvailable}
|
<div className="text-sm2 pt-0.5 font-normal text-foreground">{isAvailable ? "افزودن" : "عدم موجودی"}</div>
|
||||||
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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
disabled={!isAvailable}
|
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" />
|
<PlusIcon className="text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
<motion.div
|
<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">
|
||||||
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}
|
{quantity}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<button
|
<button onClick={handleRemoveFromCart} className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors">
|
||||||
onClick={handleRemoveFromCart}
|
|
||||||
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
|
|
||||||
>
|
|
||||||
<MinusIcon className="text-foreground" />
|
<MinusIcon className="text-foreground" />
|
||||||
</button>
|
</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 = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -8,10 +9,7 @@ type Props = {
|
|||||||
|
|
||||||
function MenuItemRendererComponent({ children, ...rest }: Props) {
|
function MenuItemRendererComponent({ children, ...rest }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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")}>
|
||||||
{...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`}>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+158
-195
@@ -1,29 +1,50 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||||
import SideMenuItem from './SideMenuItem';
|
import LogoutPrompt from "@/features/general/LogoutPrompt";
|
||||||
import NoteBoardIcon from '../icons/NoteBoardIcon';
|
import useToggle from "@/hooks/helpers/useToggle";
|
||||||
import BlurredOverlayContainer from '../overlays/BlurredOverlayContainer';
|
import { glassSurface } from "@/lib/styles/glassSurface";
|
||||||
import NightModeSwitch from '../button/NightModeSwitch';
|
import clsx from "clsx";
|
||||||
import Button from '../button/PrimaryButton';
|
import { motion, Variants } from "framer-motion";
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
import {
|
||||||
import clsx from 'clsx';
|
CalendarSearch,
|
||||||
import { useTranslation } from 'react-i18next';
|
Cup,
|
||||||
import { CalendarSearch, Cup, DirectboxReceive, DirectInbox, DocumentCopy, Game, Icon, Instagram, Like1, Login, LogoutCurve, Notification, Receipt1, Setting2, Share, TicketDiscount, Whatsapp } from 'iconsax-react';
|
DirectboxReceive,
|
||||||
import TelegramIcon from '../icons/TelegramIcon';
|
DirectInbox,
|
||||||
import Modal from '../utils/Modal';
|
DocumentCopy,
|
||||||
import LogoutPrompt from '@/features/general/LogoutPrompt';
|
Game,
|
||||||
import useToggle from '@/hooks/helpers/useToggle';
|
Icon,
|
||||||
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
|
Instagram,
|
||||||
import Image from 'next/image';
|
Like1,
|
||||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
Login,
|
||||||
import { toast, toastLoginRequired } from '../Toast';
|
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 = {
|
type MenuItemType = {
|
||||||
href: string | undefined;
|
href: string | undefined;
|
||||||
title: string;
|
title: string;
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
|
badge?: string;
|
||||||
auth?: boolean;
|
auth?: boolean;
|
||||||
guestOnly?: boolean;
|
guestOnly?: boolean;
|
||||||
premiumOnly?: boolean;
|
premiumOnly?: boolean;
|
||||||
@@ -31,22 +52,23 @@ type MenuItemType = {
|
|||||||
|
|
||||||
const menuItems: Array<Array<MenuItemType>> = [
|
const menuItems: Array<Array<MenuItemType>> = [
|
||||||
[
|
[
|
||||||
{ href: 'notifications', title: 'Notifications', icon: Notification },
|
{ href: "notifications", title: "Notifications", icon: Notification },
|
||||||
{ href: 'transactions/discount/club', title: 'CustomerClub', icon: Cup, premiumOnly: true },
|
{ href: "transactions/discount/club", title: "CustomerClub", icon: Cup, premiumOnly: true },
|
||||||
{ href: 'transactions/discount', title: 'Discounts', icon: TicketDiscount, premiumOnly: true },
|
{ href: "transactions/discount", title: "Discounts", icon: TicketDiscount, premiumOnly: true },
|
||||||
{ href: 'order/history', title: 'Orders', icon: CalendarSearch, premiumOnly: true },
|
{ href: "order/history", title: "Orders", icon: CalendarSearch, premiumOnly: true },
|
||||||
{ href: 'transactions', title: 'Transactions', icon: Receipt1, premiumOnly: true },
|
{ href: "transactions", title: "Transactions", icon: Receipt1, premiumOnly: true },
|
||||||
{ href: 'game', title: 'Games', icon: Game },
|
{ href: "game", title: "Games", icon: Game },
|
||||||
{ href: '?share', title: 'ShareWithFriends', icon: Like1 },
|
{ href: "ai", title: "AI", icon: MessageProgramming, badge: "AIBadge" },
|
||||||
{ href: 'report', title: 'ReportProblem', icon: NoteBoardIcon },
|
{ href: "?share", title: "ShareWithFriends", icon: Like1 },
|
||||||
{ href: '?installpwa', title: 'InstallApp', icon: DirectboxReceive }
|
{ href: "report", title: "ReportProblem", icon: NoteBoardIcon },
|
||||||
|
{ href: "?installpwa", title: "InstallApp", icon: DirectboxReceive },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
[
|
[
|
||||||
{ auth: true, href: 'profile/settings', title: 'Preferences', icon: Setting2 },
|
{ auth: true, href: "profile/settings", title: "Preferences", icon: Setting2 },
|
||||||
{ auth: true, href: '?logout', title: 'Logout', icon: LogoutCurve },
|
{ auth: true, href: "?logout", title: "Logout", icon: LogoutCurve },
|
||||||
{ guestOnly: true, href: 'auth', title: 'LoginSignup', icon: Login }
|
{ guestOnly: true, href: "auth", title: "LoginSignup", icon: Login },
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -58,31 +80,25 @@ type Props = {
|
|||||||
|
|
||||||
type BeforeInstallPromptEvent = Event & {
|
type BeforeInstallPromptEvent = Event & {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIOS = () => {
|
const isIOS = () => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isStandalone = () => {
|
const isStandalone = () => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
|
|
||||||
const isIOSStandalone =
|
const isIOSStandalone = "standalone" in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true;
|
||||||
('standalone' in window.navigator) &&
|
|
||||||
(window.navigator as { standalone?: boolean }).standalone === true;
|
|
||||||
|
|
||||||
const isDisplayModeStandalone =
|
const isDisplayModeStandalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches;
|
||||||
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) {
|
function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) {
|
||||||
|
|
||||||
const menuStateMemo = useMemo(() => menuState, [menuState]);
|
const menuStateMemo = useMemo(() => menuState, [menuState]);
|
||||||
const closeRef = useRef<HTMLDivElement>(null);
|
const closeRef = useRef<HTMLDivElement>(null);
|
||||||
const { state: logoutModal, toggle: toggleLogoutModal } = useToggle();
|
const { state: logoutModal, toggle: toggleLogoutModal } = useToggle();
|
||||||
@@ -95,26 +111,25 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
const { data: profile, isSuccess } = useGetProfile();
|
const { data: profile, isSuccess } = useGetProfile();
|
||||||
const profileData = profile?.data;
|
const profileData = profile?.data;
|
||||||
const isLoggedIn = isSuccess && profileData;
|
const isLoggedIn = isSuccess && profileData;
|
||||||
const isPremium = aboutData?.data?.plan === 'premium';
|
const isPremium = aboutData?.data?.plan === "premium";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { name } = params;
|
const { name } = params;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t: tMenu } = useTranslation('common', {
|
const { t: tMenu } = useTranslation("common", {
|
||||||
keyPrefix: 'SideMenu'
|
keyPrefix: "SideMenu",
|
||||||
});
|
});
|
||||||
const { t: tShareModal } = useTranslation('common', {
|
const { t: tShareModal } = useTranslation("common", {
|
||||||
keyPrefix: 'ShareModal'
|
keyPrefix: "ShareModal",
|
||||||
});
|
});
|
||||||
const { t: tInstallPwaModal } = useTranslation('common', {
|
const { t: tInstallPwaModal } = useTranslation("common", {
|
||||||
keyPrefix: 'InstallPwaModal'
|
keyPrefix: "InstallPwaModal",
|
||||||
});
|
});
|
||||||
|
|
||||||
const variants: Variants = {
|
const variants: Variants = {
|
||||||
hidden: { x: '100%', transition: { duration: 0.1, ease: 'easeInOut' } },
|
hidden: { x: "100%", transition: { duration: 0.1, ease: "easeInOut" } },
|
||||||
visible: { x: 0, transition: { delay: 0.075, duration: 0.1, ease: 'easeInOut' } }
|
visible: { x: 0, transition: { delay: 0.075, duration: 0.1, ease: "easeInOut" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,12 +148,12 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
setIsPWAInstalled(true);
|
setIsPWAInstalled(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
window.addEventListener('appinstalled', handleAppInstalled);
|
window.addEventListener("appinstalled", handleAppInstalled);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -150,14 +165,14 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
deferredPrompt.prompt();
|
deferredPrompt.prompt();
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
if (outcome === 'accepted') {
|
if (outcome === "accepted") {
|
||||||
setDeferredPrompt(null);
|
setDeferredPrompt(null);
|
||||||
toggleInstallPwaModal();
|
toggleInstallPwaModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getShareUrl = () => {
|
const getShareUrl = () => {
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === "undefined") return "";
|
||||||
return window.location.href;
|
return window.location.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,12 +181,12 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
const url = getShareUrl();
|
const url = getShareUrl();
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast('لینک با موفقیت کپی شد', 'success');
|
toast("لینک با موفقیت کپی شد", "success");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast('خطا در کپی کردن لینک', 'error');
|
toast("خطا در کپی کردن لینک", "error");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -184,84 +199,81 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
|
|
||||||
const handleShareTelegram = () => {
|
const handleShareTelegram = () => {
|
||||||
const url = encodeURIComponent(getShareUrl());
|
const url = encodeURIComponent(getShareUrl());
|
||||||
window.open(`https://t.me/share/url?url=${url}`, '_blank');
|
window.open(`https://t.me/share/url?url=${url}`, "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShareWhatsApp = () => {
|
const handleShareWhatsApp = () => {
|
||||||
const url = encodeURIComponent(getShareUrl());
|
const url = encodeURIComponent(getShareUrl());
|
||||||
const text = encodeURIComponent('اپلیکیشن ما را ببینید!');
|
const text = encodeURIComponent("اپلیکیشن ما را ببینید!");
|
||||||
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
|
window.open(`https://wa.me/?text=${text}%20${url}`, "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShareInstagram = async () => {
|
const handleShareInstagram = async () => {
|
||||||
const success = await copyToClipboard(false);
|
const success = await copyToClipboard(false);
|
||||||
if (success) {
|
if (success) {
|
||||||
toast('لینک کپی شد. لطفاً آن را در اینستاگرام به اشتراک بگذارید', 'info');
|
toast("لینک کپی شد. لطفاً آن را در اینستاگرام به اشتراک بگذارید", "info");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotificationClick = (e: React.MouseEvent) => {
|
const handleNotificationClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
toastLoginRequired(() => router.push(`/${name}/auth`), 'info');
|
toastLoginRequired(() => router.push(`/${name}/auth`), "info");
|
||||||
} else {
|
} else {
|
||||||
router.push(`/${name}/notifications`);
|
router.push(`/${name}/notifications`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hrefOnClicks = [
|
const hrefOnClicks = [
|
||||||
{ href: '?logout', handler: toggleLogoutModal },
|
{ href: "?logout", handler: toggleLogoutModal },
|
||||||
{ href: '?share', handler: toggleShareModal },
|
{ href: "?share", handler: toggleShareModal },
|
||||||
{ href: '?installpwa', handler: toggleInstallPwaModal },
|
{ href: "?installpwa", handler: toggleInstallPwaModal },
|
||||||
{ href: 'notifications', handler: handleNotificationClick }
|
{ href: "notifications", handler: handleNotificationClick },
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderMenu = () => {
|
const renderMenu = () => {
|
||||||
return (
|
return (
|
||||||
<section className="flex-1 overflow-y-scroll noscrollbar pt-6 pb-8 flex flex-col justify-between md:w-[200px] xl:w-[250px]">
|
<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 && (
|
||||||
aboutData?.data?.logo && (
|
<div>
|
||||||
<div >
|
<Image className="mx-auto" src={aboutData?.data?.logo} alt="logo" width={110} height={100} unoptimized />
|
||||||
<Image className='mx-auto' src={aboutData?.data?.logo} alt='logo' width={110} height={100} unoptimized />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
<div className="mt-4">
|
||||||
<div className='mt-4'>
|
|
||||||
<header className="px-5 md:px-6 xl:px-12">
|
<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>
|
<h6 className="text-start font-bold text-sm text-menu-header mt-2 mb-4.75 dark:text-disabled-text">منو</h6>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav aria-label={tMenu('NavAriaLabel')}>
|
<nav aria-label={tMenu("NavAriaLabel")}>
|
||||||
<ul>
|
<ul>
|
||||||
{menuItems[0]
|
{menuItems[0]
|
||||||
.filter(item => {
|
.filter((item) => {
|
||||||
if (item.auth && !isSuccess) return false;
|
if (item.auth && !isSuccess) return false;
|
||||||
if (item.guestOnly && isSuccess) return false;
|
if (item.guestOnly && isSuccess) return false;
|
||||||
if (item.href === '?installpwa' && isPWAInstalled) return false;
|
if (item.href === "?installpwa" && isPWAInstalled) return false;
|
||||||
if (item.premiumOnly && !isPremium) return false;
|
if (item.premiumOnly && !isPremium) return false;
|
||||||
|
if (item.href === "ai" && !aboutData?.data?.enableAiChat) return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(({ icon: Icon, ...item }, index) => {
|
.map(({ icon: Icon, ...item }, index) => {
|
||||||
const href = `/${name}/${item.href}`;
|
const href = `/${name}/${item.href}`;
|
||||||
const isActive = pathname === href;
|
const isActive = pathname === href;
|
||||||
return (
|
return (
|
||||||
<li key={index} className="mt-4 h-6">
|
<li key={index} className={clsx("mt-4", item.badge ? "h-8" : "h-6")}>
|
||||||
<SideMenuItem
|
<SideMenuItem
|
||||||
className={clsx(isActive && 'text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!', 'border-r-4!')}
|
className={clsx(isActive && "text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!", "border-r-4!")}
|
||||||
href={href ?? ''}
|
href={href ?? ""}
|
||||||
title={tMenu(item.title)}
|
title={tMenu(item.title)}
|
||||||
onClick={
|
badge={item.badge ? tMenu(item.badge) : undefined}
|
||||||
typeof item.href === 'string'
|
onClick={typeof item.href === "string" ? hrefOnClicks.find((i) => i.href === item.href)?.handler : undefined}
|
||||||
? hrefOnClicks.find((i) => i.href === item.href)?.handler
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
icon={
|
icon={
|
||||||
<Icon
|
<Icon
|
||||||
variant={isActive ? 'Bold' : 'Linear'}
|
variant={isActive ? "Bold" : "Linear"}
|
||||||
className={clsx(
|
className={clsx(isActive ? "text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200" : "stroke-icon-deactive text-icon-deactive")}
|
||||||
isActive ? 'text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200' : 'stroke-icon-deactive text-icon-deactive'
|
size={20}
|
||||||
)}
|
width={20}
|
||||||
size={20} width={20} height={20} />
|
height={20}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -271,10 +283,10 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section aria-label={tMenu('ControlsAriaLabel')}>
|
<section aria-label={tMenu("ControlsAriaLabel")}>
|
||||||
<ul className="flex flex-col pt-16">
|
<ul className="flex flex-col pt-16">
|
||||||
{menuItems[2]
|
{menuItems[2]
|
||||||
.filter(item => {
|
.filter((item) => {
|
||||||
if (item.auth && !isSuccess) return false;
|
if (item.auth && !isSuccess) return false;
|
||||||
if (item.guestOnly && isSuccess) return false;
|
if (item.guestOnly && isSuccess) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -286,177 +298,128 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
<li key={index} className="mt-4 h-6 w-full">
|
<li key={index} className="mt-4 h-6 w-full">
|
||||||
<SideMenuItem
|
<SideMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
href={href ?? ''}
|
href={href ?? ""}
|
||||||
className={clsx(isActive && 'text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!', 'border-r-4!')}
|
className={clsx(isActive && "text-primary! border-primary! dark:text-neutral-200! dark:border-neutral-200!", "border-r-4!")}
|
||||||
onClick={
|
onClick={typeof item.href === "string" ? hrefOnClicks.find((i) => i.href === item.href)?.handler : undefined}
|
||||||
typeof item.href === 'string'
|
|
||||||
? hrefOnClicks.find((i) => i.href === item.href)?.handler
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
title={tMenu(item.title)}
|
title={tMenu(item.title)}
|
||||||
icon={
|
icon={
|
||||||
<Icon
|
<Icon
|
||||||
variant={isActive ? 'Bold' : 'Linear'}
|
variant={isActive ? "Bold" : "Linear"}
|
||||||
className={clsx(
|
className={clsx(isActive ? "text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200" : "stroke-icon-deactive text-icon-deactive")}
|
||||||
isActive ? 'text-primary fill-primary dark:text-neutral-200 dark:fill-neutral-200' : 'stroke-icon-deactive text-icon-deactive'
|
size={20}
|
||||||
)}
|
width={20}
|
||||||
size={20} width={20} height={20} />
|
height={20}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
<li className="mt-4 h-6">
|
<li className="mt-4 h-6">
|
||||||
<SideMenuItem
|
<SideMenuItem className="pr-7" href={""} title={""} icon={<></>}>
|
||||||
className='pr-7'
|
<NightModeSwitch checked={nightModeState} onClick={togglenightModeState} />
|
||||||
href={''}
|
|
||||||
title={''}
|
|
||||||
icon={<></>}>
|
|
||||||
<NightModeSwitch
|
|
||||||
checked={nightModeState}
|
|
||||||
onClick={togglenightModeState}
|
|
||||||
/>
|
|
||||||
</SideMenuItem>
|
</SideMenuItem>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='w-full' ref={closeRef} onClick={toggleMenuState}>
|
<div className="w-full" ref={closeRef} onClick={toggleMenuState}>
|
||||||
<aside aria-label={tMenu('AriaLabel')}>
|
<aside aria-label={tMenu("AriaLabel")}>
|
||||||
<BlurredOverlayContainer className='xl:hidden!' bgOpacity={40} visible={menuStateMemo} outDelay={150} inDuration={200}>
|
<BlurredOverlayContainer className="xl:hidden!" bgOpacity={40} visible={menuStateMemo} outDelay={150} inDuration={200}>
|
||||||
<motion.nav
|
<motion.nav
|
||||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={menuState ? 'visible' : 'hidden'}
|
animate={menuState ? "visible" : "hidden"}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
data-visible={menuState}
|
data-visible={menuState}
|
||||||
data-isvisible={menuStateMemo}
|
data-isvisible={menuStateMemo}
|
||||||
onClick={(e) => { e.stopPropagation(); }}
|
onClick={(e) => {
|
||||||
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!"
|
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()}
|
{renderMenu()}
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
</BlurredOverlayContainer>
|
</BlurredOverlayContainer>
|
||||||
<nav
|
<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>
|
||||||
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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout Modal */}
|
{/* Logout Modal */}
|
||||||
<LogoutPrompt
|
<LogoutPrompt visible={logoutModal} onClick={toggleLogoutModal} slug={name as string} />
|
||||||
visible={logoutModal}
|
|
||||||
onClick={toggleLogoutModal}
|
|
||||||
slug={name as string}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Share modal */}
|
{/* Share modal */}
|
||||||
<Modal
|
<Modal visible={shareModal} onClick={toggleShareModal}>
|
||||||
visible={shareModal}
|
|
||||||
onClick={toggleShareModal}
|
|
||||||
>
|
|
||||||
<h2 className="text-base font-medium">
|
<h2 className="text-base font-medium">
|
||||||
<Share className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
<Share className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||||
{tShareModal('Heading')}
|
{tShareModal("Heading")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs font-medium mt-6">
|
<p className="text-xs font-medium mt-6">{tShareModal("Description")}</p>
|
||||||
{tShareModal('Description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button onClick={handleCopyLink} className="text-sm mt-8">
|
<Button onClick={handleCopyLink} className="text-sm mt-8">
|
||||||
<DocumentCopy className="inline-block ml-2 mb-0.5 stroke-container dark:stroke-foreground" size={20} />
|
<DocumentCopy className="inline-block ml-2 mb-0.5 stroke-container dark:stroke-foreground" size={20} />
|
||||||
<span className="font-light">{tShareModal('ButtonCopy')}</span>
|
<span className="font-light">{tShareModal("ButtonCopy")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-5.5 mt-6 px-12">
|
<div className="grid grid-cols-3 gap-5.5 mt-6 px-12">
|
||||||
<button
|
<button onClick={handleShareTelegram} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||||
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" />
|
<TelegramIcon width={24} height={24} className="mb-1.5 text-primary dark:text-foreground" />
|
||||||
<span className='text-xs2 font-medium'>Telegram</span>
|
<span className="text-xs2 font-medium">Telegram</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleShareWhatsApp} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||||
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" />
|
<Whatsapp size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||||
<span className='text-xs2 font-medium'>WhatsApp</span>
|
<span className="text-xs2 font-medium">WhatsApp</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleShareInstagram} className="flex flex-col items-center cursor-pointer hover:opacity-80 transition-opacity">
|
||||||
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" />
|
<Instagram size={24} className="mb-1.5 stroke-primary dark:stroke-foreground" />
|
||||||
<span className='text-xs2 font-medium'>Instagram</span>
|
<span className="text-xs2 font-medium">Instagram</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Install PWA modal */}
|
{/* Install PWA modal */}
|
||||||
<Modal
|
<Modal visible={installPwaModal} onClick={toggleInstallPwaModal}>
|
||||||
visible={installPwaModal}
|
|
||||||
onClick={toggleInstallPwaModal}
|
|
||||||
>
|
|
||||||
<h2 className="text-base font-medium">
|
<h2 className="text-base font-medium">
|
||||||
<DirectInbox className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
<DirectInbox className="inline-block ml-2 mb-1.5 stroke-primary dark:stroke-foreground" size={24} />
|
||||||
{tInstallPwaModal('Heading')}
|
{tInstallPwaModal("Heading")}
|
||||||
</h2>
|
</h2>
|
||||||
{isPWAInstalled ? (
|
{isPWAInstalled ? (
|
||||||
<p className="mt-6 text-xs font-medium text-center text-foreground/80 dark:text-neutral-200">
|
<p className="mt-6 text-xs font-medium text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("AlreadyInstalled")}</p>
|
||||||
{tInstallPwaModal('AlreadyInstalled')}
|
|
||||||
</p>
|
|
||||||
) : isIOSDevice ? (
|
) : isIOSDevice ? (
|
||||||
<div className="mt-5 rounded-3xl bg-primary/5 px-4 py-5 backdrop-blur-sm dark:bg-white/5">
|
<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">
|
<p className="text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("IOSDescription")}</p>
|
||||||
{tInstallPwaModal('IOSDescription')}
|
|
||||||
</p>
|
|
||||||
<ol className="mt-5 space-y-3 text-xs font-medium rtl:text-right">
|
<ol className="mt-5 space-y-3 text-xs font-medium rtl:text-right">
|
||||||
<li className="flex items-start gap-2">
|
<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 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>
|
||||||
</span>
|
|
||||||
<span>{tInstallPwaModal('IOSStep1')}</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<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 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>
|
||||||
</span>
|
|
||||||
<span>{tInstallPwaModal('IOSStep2')}</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<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 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>
|
||||||
</span>
|
|
||||||
<span>{tInstallPwaModal('IOSStep3')}</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mt-6 text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">
|
<p className="mt-6 text-xs font-medium leading-relaxed text-center text-foreground/80 dark:text-neutral-200">{tInstallPwaModal("Description")}</p>
|
||||||
{tInstallPwaModal('Description')}
|
|
||||||
</p>
|
|
||||||
{deferredPrompt ? (
|
{deferredPrompt ? (
|
||||||
<Button onClick={handleInstallPWA} className="mt-8 text-sm">
|
<Button onClick={handleInstallPWA} className="mt-8 text-sm">
|
||||||
<span className="font-light">{tInstallPwaModal('ButtonOk')}</span>
|
<span className="font-light">{tInstallPwaModal("ButtonOk")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-4 text-[11px] font-medium leading-relaxed text-center text-foreground/60 dark:text-neutral-300">
|
<p className="mt-4 text-[11px] font-medium leading-relaxed text-center text-foreground/60 dark:text-neutral-300">{tInstallPwaModal("ChromeFallback")}</p>
|
||||||
{tInstallPwaModal('ChromeFallback')}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Link, { LinkProps } from 'next/link'
|
import Link, { LinkProps } from "next/link";
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: React.ReactElement
|
icon: React.ReactElement;
|
||||||
title: string
|
title: string;
|
||||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
badge?: string;
|
||||||
|
} & LinkProps &
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||||
|
|
||||||
export default function SideMenuItem({ href, title, icon, className, children, ...restProps }: Props) {
|
export default function SideMenuItem({ href, title, icon, badge, className, children, ...restProps }: Props) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -23,7 +25,11 @@ export default function SideMenuItem({ href, title, icon, className, children, .
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span className="shrink-0">{icon}</span>
|
<span className="shrink-0">{icon}</span>
|
||||||
<span className='mt-0.5'>{title}</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>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,79 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react'
|
import { useGetCartItems } from "@/app/[name]/(Main)/cart/hooks/useCartData";
|
||||||
import Link from 'next/link'
|
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||||
import BottomNavLink from './BottomNavLink'
|
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||||
import BottomNavHighlightLink from './BottomNavLinkBig'
|
import { useReceiptStore } from "@/zustand/receiptStore";
|
||||||
import PagerIcon from '../icons/PagerIcon'
|
import clsx from "clsx";
|
||||||
import BagIcon from '../icons/BagIcon'
|
import type { Icon } from "iconsax-react";
|
||||||
import HeartIcon from '../icons/HeartIcon'
|
import { Bag2, Heart, Home2, InfoCircle, Reserve } from "iconsax-react";
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
import Link from "next/link";
|
||||||
import HomeIcon from '../icons/HomeIcon'
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useMemo } from "react";
|
||||||
import { useCart } from '@/app/[name]/(Main)/cart/hook/useCart';
|
import { toastLoginRequired } from "../Toast";
|
||||||
import { Building } from 'iconsax-react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
|
||||||
import { toastLoginRequired } from '../Toast';
|
|
||||||
|
|
||||||
type BottomNavBarProps = {
|
type BottomNavBarProps = {
|
||||||
onPagerClick?: () => void;
|
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) {
|
function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { name } = params;
|
const name = params.name as string;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isHomeRoute = pathname === `/${name}`;
|
const router = useRouter();
|
||||||
const isAboutRoute = pathname.includes('about');
|
const { isSuccess } = useGetProfile();
|
||||||
const buildingVariant = React.useMemo(() => {
|
const { data: cartData } = useGetCartItems(isSuccess);
|
||||||
return (isAboutRoute ? 'Bold' : 'Outline') as 'Bold' | 'Outline';
|
const localItems = useReceiptStore((state) => state.items);
|
||||||
}, [isAboutRoute]);
|
|
||||||
const { isSuccess } = useGetProfile()
|
|
||||||
const router = useRouter()
|
|
||||||
const { t } = useTranslation('common', {
|
|
||||||
keyPrefix: 'BottomNavbar'
|
|
||||||
});
|
|
||||||
const { items } = useCart();
|
|
||||||
|
|
||||||
const cartItemsCount = React.useMemo(() => {
|
const guestItemCount = useMemo(
|
||||||
return Object.values(items).reduce((total, item) => {
|
() => Object.values(localItems).reduce((sum, item) => sum + (item?.quantity ?? 0), 0),
|
||||||
return total + (item?.quantity || 0);
|
[localItems],
|
||||||
}, 0);
|
);
|
||||||
}, [items]);
|
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>) => {
|
const handleFavoritesClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -49,150 +81,19 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
|
|||||||
router.push(`/${name}/favorite`);
|
router.push(`/${name}/favorite`);
|
||||||
} else {
|
} else {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toastLoginRequired(() => router.push(`/${name}/auth`), 'error');
|
toastLoginRequired(() => router.push(`/${name}/auth`), "error");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 w-full bg-transparent pointer-events-none">
|
<div className={glassSurfaceNav("mx-auto w-full rounded-full flex items-center h-14 max-w-md gap-2.5")}>
|
||||||
<div className="max-w-md mx-auto w-full aspect-436/104 relative z-30">
|
<NavItem href={`/${name}`} isActive={isHomeActive} Icon={Home2} />
|
||||||
<svg
|
<NavItem href={`/${name}/favorite`} isActive={isFavoriteActive} onClick={handleFavoritesClick} Icon={Heart} />
|
||||||
viewBox="0 0 436 104"
|
{onPagerClick ? <NavItem isActive={isPagerActive} onButtonClick={onPagerClick} Icon={Reserve} /> : <NavItem href={`/${name}/pager`} isActive={isPagerActive} Icon={Reserve} />}
|
||||||
fill="none"
|
<NavItem href={`/${name}/about`} isActive={isAboutActive} Icon={InfoCircle} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<NavItem href={`/${name}/cart`} isActive={isCartActive} Icon={Bag2} badge={cartItemCount} />
|
||||||
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>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BottomNavBar
|
export default BottomNavBar;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { LinkProps } from 'next/link'
|
import type { LinkProps } from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,22 +11,17 @@ type Props = {
|
|||||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||||
|
|
||||||
function BottomNavLink({ icon, value, href, prefetch, ...restProps }: Props) {
|
function BottomNavLink({ icon, value, href, prefetch, ...restProps }: Props) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isActive = pathname === href;
|
|
||||||
const shouldPrefetch = prefetch ?? true;
|
const shouldPrefetch = prefetch ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link {...restProps} href={href} prefetch={shouldPrefetch} className={clsx(
|
<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',
|
'flex flex-col justify-arround items-center gap-[5px] pointer-events-auto min-w-0 text-primary **:stroke-primary',
|
||||||
isActive && 'text-primary-foreground **:stroke-primary-foreground',
|
|
||||||
restProps.className ?? '',
|
restProps.className ?? '',
|
||||||
)}>
|
)}>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className={clsx(
|
<span className="text-xs2 text-center truncate w-full">
|
||||||
'text-xs2 text-center truncate w-full dark:text-white',
|
|
||||||
)}>
|
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { LinkProps } from 'next/link'
|
import type { LinkProps } from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,9 +11,6 @@ type Props = {
|
|||||||
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||||
|
|
||||||
function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
|
function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isActive = pathname === href;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link {...restProps} href={href} className={clsx(
|
<Link {...restProps} href={href} className={clsx(
|
||||||
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto',
|
'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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs2 dark:text-white">
|
<span className="text-xs2 text-primary">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useLayoutEffect, useState } from 'react'
|
||||||
import PingAnimation from './animations/PingAnimation';
|
import PingAnimation from './animations/PingAnimation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,21 +10,23 @@ type Props = {
|
|||||||
} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||||
|
|
||||||
const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children = <PingAnimation /> }: Props) => {
|
const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children = <PingAnimation /> }: Props) => {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(visible);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// Trigger fade-in after mount
|
setLoaded(visible);
|
||||||
const timeout = setTimeout(() => setLoaded(visible), 300);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-visible={visible}
|
data-visible={visible}
|
||||||
data-loaded={loaded}
|
data-loaded={loaded}
|
||||||
className={`absolute inset-0 flex items-center justify-center
|
className={`absolute inset-0 flex items-center justify-center
|
||||||
backdrop-blur-sm bg-background/${bgOpacity}
|
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-[loaded=false]:-z-50
|
||||||
data-[visible=true]:z-50
|
data-[visible=true]:z-50
|
||||||
data-[visible=true]:opacity-100`}
|
data-[visible=true]:opacity-100`}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { motion } from "framer-motion";
|
||||||
import { motion } from 'framer-motion';
|
import Image from "next/image";
|
||||||
import Image from 'next/image';
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const MAX_DISPLAY_MS = 1200;
|
const MAX_DISPLAY_MS = 1200;
|
||||||
const EXIT_MS = 350;
|
const EXIT_MS = 350;
|
||||||
@@ -14,27 +14,21 @@ interface SplashScreenProps {
|
|||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SplashScreen({
|
export default function SplashScreen({ logo, restaurantName, onDismiss }: SplashScreenProps) {
|
||||||
logo,
|
|
||||||
restaurantName,
|
|
||||||
onDismiss,
|
|
||||||
}: SplashScreenProps) {
|
|
||||||
const [opacity, setOpacity] = useState(1);
|
const [opacity, setOpacity] = useState(1);
|
||||||
const [logoState, setLogoState] = useState<'idle' | 'loading' | 'ready' | 'error'>(
|
const [logoState, setLogoState] = useState<"idle" | "loading" | "ready" | "error">(logo ? "loading" : "idle");
|
||||||
logo ? 'loading' : 'idle',
|
|
||||||
);
|
|
||||||
const onDismissRef = useRef(onDismiss);
|
const onDismissRef = useRef(onDismiss);
|
||||||
onDismissRef.current = onDismiss;
|
onDismissRef.current = onDismiss;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogoState(logo ? 'loading' : 'idle');
|
setLogoState(logo ? "loading" : "idle");
|
||||||
}, [logo]);
|
}, [logo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!logo || logoState !== 'loading') return;
|
if (!logo || logoState !== "loading") return;
|
||||||
|
|
||||||
const logoTimeout = setTimeout(() => {
|
const logoTimeout = setTimeout(() => {
|
||||||
setLogoState('error');
|
setLogoState("error");
|
||||||
}, LOGO_TIMEOUT_MS);
|
}, LOGO_TIMEOUT_MS);
|
||||||
|
|
||||||
return () => clearTimeout(logoTimeout);
|
return () => clearTimeout(logoTimeout);
|
||||||
@@ -55,14 +49,13 @@ export default function SplashScreen({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showLogoSpinner = Boolean(logo) && logoState === 'loading';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
animate={{ opacity }}
|
animate={{ opacity }}
|
||||||
transition={{ duration: EXIT_MS / 1000, ease: 'easeOut' }}
|
transition={{ duration: EXIT_MS / 1000, ease: "easeOut" }}
|
||||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background"
|
className="fixed inset-0 z-9999 flex flex-col items-center justify-center bg-background"
|
||||||
|
data-slot="splash-screen"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
@@ -74,7 +67,7 @@ export default function SplashScreen({
|
|||||||
transition={{
|
transition={{
|
||||||
duration: 3,
|
duration: 3,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
className="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
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,
|
duration: 3,
|
||||||
delay: 0.5,
|
delay: 0.5,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
className="absolute -bottom-20 -left-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
className="absolute -bottom-20 -left-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-8 px-8 w-full max-w-md relative z-10">
|
<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">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={logo}
|
src={logo}
|
||||||
alt={restaurantName || 'Restaurant Logo'}
|
alt={restaurantName || "Restaurant Logo"}
|
||||||
width={200}
|
width={200}
|
||||||
height={100}
|
height={100}
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
priority
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
onLoad={() => setLogoState('ready')}
|
onLoad={() => setLogoState("ready")}
|
||||||
onError={() => setLogoState('error')}
|
onError={() => setLogoState("error")}
|
||||||
style={{
|
style={{
|
||||||
opacity: logoState === 'ready' ? 1 : 0,
|
opacity: logoState === "ready" ? 1 : 0,
|
||||||
transition: 'opacity 0.3s',
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -131,7 +119,7 @@ export default function SplashScreen({
|
|||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
delay,
|
delay,
|
||||||
repeat: Infinity,
|
repeat: Infinity,
|
||||||
ease: 'easeInOut',
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { ReactElement, ReactNode, useCallback, useState } from 'react'
|
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
|
||||||
import HorizontalScrollView from '../listview/HorizontalScrollView'
|
import clsx from "clsx";
|
||||||
import { TabHeader, TabItemProps } from './TabHeader';
|
import Image from "next/image";
|
||||||
import clsx from 'clsx';
|
import React, { ReactElement, ReactNode, useCallback, useState } from "react";
|
||||||
import TabHeaderRenderer from './TabHeaderRenderer';
|
import HorizontalScrollView from "../listview/HorizontalScrollView";
|
||||||
import Image from 'next/image';
|
import { TabHeader, TabItemProps } from "./TabHeader";
|
||||||
|
import TabHeaderRenderer from "./TabHeaderRenderer";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
|
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
|
||||||
@@ -15,7 +16,7 @@ type Props = {
|
|||||||
defaultIndex?: number;
|
defaultIndex?: number;
|
||||||
selectedTab?: number;
|
selectedTab?: number;
|
||||||
onTabChange?: (index: number) => void;
|
onTabChange?: (index: number) => void;
|
||||||
} & Omit<React.OlHTMLAttributes<HTMLUListElement>, 'className'>;
|
} & Omit<React.OlHTMLAttributes<HTMLUListElement>, "className">;
|
||||||
|
|
||||||
export enum TabContainerRenderType {
|
export enum TabContainerRenderType {
|
||||||
RENDERER,
|
RENDERER,
|
||||||
@@ -33,106 +34,78 @@ export type TabContainerClassName = {
|
|||||||
iconActive?: string;
|
iconActive?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
titleDeactive?: string;
|
titleDeactive?: string;
|
||||||
titleActive?: string
|
titleActive?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TabContainerClassNames: TabContainerClassName = {
|
export const TabContainerClassNames: TabContainerClassName = {
|
||||||
wrapper: 'h-full',
|
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',
|
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: '',
|
header: "",
|
||||||
headerDeactive: '',
|
headerDeactive: "",
|
||||||
headerActive: '',
|
headerActive: "",
|
||||||
icon: 'text-xs transition-all duration-150',
|
icon: "text-xs transition-all duration-150",
|
||||||
iconDeactive: 'stroke-disabled-text',
|
iconDeactive: "stroke-disabled-text",
|
||||||
iconActive: 'stroke-primary dark:stroke-foreground',
|
iconActive: "stroke-primary dark:stroke-foreground",
|
||||||
title: 'text-xs transition-all duration-150',
|
title: "text-xs transition-all duration-150",
|
||||||
titleDeactive: 'text-disabled-text',
|
titleDeactive: "text-disabled-text",
|
||||||
titleActive: 'text-primary dark:text-foreground'
|
titleActive: "text-primary dark:text-foreground",
|
||||||
}
|
};
|
||||||
|
|
||||||
function TabContainer({ className = TabContainerClassNames, changeType = TabContainerRenderType.RENDERER, defaultIndex = 0, selectedTab: controlledSelectedTab, onTabChange, itemRenderer, children, ...restProps }: Props) {
|
|
||||||
|
|
||||||
|
function TabContainer({
|
||||||
|
className = TabContainerClassNames,
|
||||||
|
changeType = TabContainerRenderType.RENDERER,
|
||||||
|
defaultIndex = 0,
|
||||||
|
selectedTab: controlledSelectedTab,
|
||||||
|
onTabChange,
|
||||||
|
itemRenderer,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: Props) {
|
||||||
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultIndex);
|
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultIndex);
|
||||||
const selectedTab = controlledSelectedTab !== undefined ? controlledSelectedTab : internalSelectedTab;
|
const selectedTab = controlledSelectedTab !== undefined ? controlledSelectedTab : internalSelectedTab;
|
||||||
|
|
||||||
const updateTab = useCallback((index: number) => {
|
const updateTab = useCallback(
|
||||||
|
(index: number) => {
|
||||||
if (onTabChange) {
|
if (onTabChange) {
|
||||||
onTabChange(index);
|
onTabChange(index);
|
||||||
} else {
|
} else {
|
||||||
setInternalSelectedTab(index);
|
setInternalSelectedTab(index);
|
||||||
}
|
}
|
||||||
}, [onTabChange]);
|
},
|
||||||
|
[onTabChange],
|
||||||
const items = React.Children.toArray(children).filter(
|
|
||||||
(child): child is ReactElement<TabItemProps> =>
|
|
||||||
React.isValidElement(child) && child.type === TabHeader
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const items = React.Children.toArray(children).filter((child): child is ReactElement<TabItemProps> => React.isValidElement(child) && child.type === TabHeader);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className.wrapper}>
|
<div className={className.wrapper}>
|
||||||
<HorizontalScrollView
|
<HorizontalScrollView className={className.scrollView} itemRenderer={itemRenderer} {...restProps}>
|
||||||
className={className.scrollView}
|
|
||||||
itemRenderer={itemRenderer}
|
|
||||||
{...restProps}>
|
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const { title, imageSrc, icon }: TabItemProps = item.props;
|
const { title, imageSrc, icon }: TabItemProps = item.props;
|
||||||
return (
|
return (
|
||||||
<TabHeaderRenderer
|
<TabHeaderRenderer key={index} className={clsx(className.header, index === selectedTab ? className.headerActive : className.headerDeactive)} onClick={() => updateTab(index)}>
|
||||||
key={index}
|
{imageSrc && <Image priority src={imageSrc} width={24} height={24} alt="category image" />}
|
||||||
className={clsx(
|
{icon && <span className={clsx(className.icon, index === selectedTab ? className.iconActive : className.iconDeactive)}>{icon}</span>}
|
||||||
className.header,
|
<h6 className={clsx(className.title, index === selectedTab ? className.titleActive : className.titleDeactive)}>{title}</h6>
|
||||||
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>
|
</TabHeaderRenderer>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</HorizontalScrollView>
|
</HorizontalScrollView>
|
||||||
|
|
||||||
{changeType === TabContainerRenderType.RENDERER &&
|
{changeType === TabContainerRenderType.RENDERER && <div>{items[selectedTab]?.props?.viewRenderer ?? <div></div>}</div>}
|
||||||
<div>
|
{changeType === TabContainerRenderType.VISIBILITY && (
|
||||||
{items[selectedTab]?.props?.viewRenderer ?? <div></div>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{changeType === TabContainerRenderType.VISIBILITY &&
|
|
||||||
<>
|
<>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className={clsx(
|
<div key={index} className={clsx(index === selectedTab ? "visible" : "hidden")}>
|
||||||
index === selectedTab ? 'visible' : 'hidden'
|
|
||||||
)}>
|
|
||||||
{item.props.viewRenderer}
|
{item.props.viewRenderer}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</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