Compare commits
27 Commits
9d48c0534e
...
3b89bbfec3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b89bbfec3 | ||
|
|
76d5652252 | ||
|
|
2da2defde7 | ||
|
|
e9a5c4b7b3 | ||
|
|
e7ce4d6808 | ||
|
|
0602bde788 | ||
|
|
1d1d3cd461 | ||
|
|
83c2102afe | ||
|
|
0c080d22cd | ||
|
|
9342494a36 | ||
|
|
c22df8231e | ||
|
|
9e0760d9c5 | ||
|
|
c1189164b8 | ||
|
|
efad306617 | ||
|
|
1efb435dad | ||
|
|
43896b4caf | ||
|
|
f18e19aff7 | ||
|
|
3e3351a9c3 | ||
|
|
e484b85bf5 | ||
|
|
1b8eadbf19 | ||
|
|
e31a08df41 | ||
|
|
71da2b53fe | ||
|
|
e652c255c9 | ||
|
|
60d1aac55b | ||
|
|
d7051c6b16 | ||
|
|
d2f3a96ddf | ||
|
|
9310062616 |
@@ -5,7 +5,7 @@
|
||||
"name": "modstagram-next-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"port": 3004
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.1.5",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "0.1.0",
|
||||
"version": "4.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3004",
|
||||
|
||||
1
public/images/icons/brand/instagram-brand.svg
Normal file
1
public/images/icons/brand/instagram-brand.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 102 102" id="instagram"><defs><radialGradient id="a" cx="6.601" cy="99.766" r="129.502" gradientUnits="userSpaceOnUse"><stop offset=".09" stop-color="#fa8f21"></stop><stop offset=".78" stop-color="#d82d7e"></stop></radialGradient><radialGradient id="b" cx="70.652" cy="96.49" r="113.963" gradientUnits="userSpaceOnUse"><stop offset=".64" stop-color="#8c3aaa" stop-opacity="0"></stop><stop offset="1" stop-color="#8c3aaa"></stop></radialGradient></defs><path fill="url(#a)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="url(#b)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="#fff" d="M461.114,477.413a12.631,12.631,0,1,1,12.629,12.632,12.631,12.631,0,0,1-12.629-12.632m-6.829,0a19.458,19.458,0,1,0,19.458-19.458,19.457,19.457,0,0,0-19.458,19.458m35.139-20.229a4.547,4.547,0,1,0,4.549-4.545h0a4.549,4.549,0,0,0-4.547,4.545m-30.99,51.074a20.943,20.943,0,0,1-7.037-1.3,12.547,12.547,0,0,1-7.193-7.19,20.923,20.923,0,0,1-1.3-7.037c-.184-3.994-.22-5.194-.22-15.313s.04-11.316.22-15.314a21.082,21.082,0,0,1,1.3-7.037,12.54,12.54,0,0,1,7.193-7.193,20.924,20.924,0,0,1,7.037-1.3c3.994-.184,5.194-.22,15.309-.22s11.316.039,15.314.221a21.082,21.082,0,0,1,7.037,1.3,12.541,12.541,0,0,1,7.193,7.193,20.926,20.926,0,0,1,1.3,7.037c.184,4,.22,5.194.22,15.314s-.037,11.316-.22,15.314a21.023,21.023,0,0,1-1.3,7.037,12.547,12.547,0,0,1-7.193,7.19,20.925,20.925,0,0,1-7.037,1.3c-3.994.184-5.194.22-15.314.22s-11.316-.037-15.309-.22m-.314-68.509a27.786,27.786,0,0,0-9.2,1.76,19.373,19.373,0,0,0-11.083,11.083,27.794,27.794,0,0,0-1.76,9.2c-.187,4.04-.229,5.332-.229,15.623s.043,11.582.229,15.623a27.793,27.793,0,0,0,1.76,9.2,19.374,19.374,0,0,0,11.083,11.083,27.813,27.813,0,0,0,9.2,1.76c4.042.184,5.332.229,15.623.229s11.582-.043,15.623-.229a27.8,27.8,0,0,0,9.2-1.76,19.374,19.374,0,0,0,11.083-11.083,27.716,27.716,0,0,0,1.76-9.2c.184-4.043.226-5.332.226-15.623s-.043-11.582-.226-15.623a27.786,27.786,0,0,0-1.76-9.2,19.379,19.379,0,0,0-11.08-11.083,27.748,27.748,0,0,0-9.2-1.76c-4.041-.185-5.332-.229-15.621-.229s-11.583.043-15.626.229" transform="translate(-422.637 -426.196)"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
14
public/images/icons/brand/phone-brand.svg
Normal file
14
public/images/icons/brand/phone-brand.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#00FF00;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M9.3,48h29.3c5.2,0,9.3-4.2,9.3-9.3V9.3C48,4.2,43.8,0,38.7,0H9.3C4.2,0,0,4.2,0,9.3v29.3C0,43.8,4.2,48,9.3,48
|
||||
z"/>
|
||||
<path class="st1" d="M39.3,33.4l-6.1-4.1c-0.8-0.5-1.7-0.5-2.4,0c0,0,0,0-2.6,1.5c-0.4,0.2-0.7,0.2-1,0.2c-0.3-0.1-0.6-0.3-0.6-0.3
|
||||
c-1.4-1.1-3.1-2.5-4.9-4.3c-1.8-1.8-3.2-3.5-4.3-4.9c0,0-0.2-0.3-0.3-0.6c0-0.3,0-0.6,0.2-1c1.5-2.6,1.5-2.6,1.5-2.6
|
||||
c0.4-0.8,0.4-1.7,0-2.4l-4.1-6.1c-0.5-0.8-1.5-1-2.3-0.4c0,0-1.9,1.3-2.6,1.8c-1,0.7-1.3,1.8-1.3,3.3c0,2.1,1.8,8.8,9.6,16.6l0,0
|
||||
l0,0l0,0l0,0c7.8,7.8,14.5,9.6,16.6,9.6c1.5,0,2.6-0.3,3.3-1.3c0.4-0.6,1.8-2.6,1.8-2.6C40.2,34.9,40,33.9,39.3,33.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 953 B |
16
public/images/icons/brand/telegram-brand.svg
Normal file
16
public/images/icons/brand/telegram-brand.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#29B6F6;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{fill:#B0BEC5;}
|
||||
.st3{fill:#CFD8DC;}
|
||||
</style>
|
||||
<path class="st0" d="M24,0C10.7,0,0,10.7,0,24s10.7,24,24,24s24-10.7,24-24S37.3,0,24,0z"/>
|
||||
<path class="st1" d="M35.9,13.2l-4.5,23c0,0-0.2,1-1.5,1c-0.7,0-1-0.3-1-0.3l-9.7-8.1l-4.8-2.4l-6.1-1.6c0,0-1.1-0.3-1.1-1.2
|
||||
c0-0.8,1.1-1.1,1.1-1.1l25.6-10.2c0,0,0.8-0.3,1.4-0.3c0.4,0,0.8,0.1,0.8,0.6C36,12.9,35.9,13.2,35.9,13.2z"/>
|
||||
<path class="st2" d="M22.8,31.8l-4.1,4c0,0-0.2,0.1-0.4,0.1c-0.1,0-0.2,0-0.3-0.1l1.2-7.2L22.8,31.8z"/>
|
||||
<path class="st3" d="M31.1,17c-0.2-0.3-0.6-0.3-0.8-0.1l-15.8,9.5c0,0,2.5,7.1,2.9,8.3c0.4,1.2,0.7,1.3,0.7,1.3l1.2-7.2L31,17.9
|
||||
C31.2,17.7,31.3,17.3,31.1,17z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 955 B |
26
public/images/icons/brand/whatsapp-brand.svg
Normal file
26
public/images/icons/brand/whatsapp-brand.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#CFD8DC;}
|
||||
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#40C351;}
|
||||
</style>
|
||||
<path class="st0" d="M1.3,46.8l3.2-11.7c-2-3.4-3-7.3-3-11.3c0-12.4,10.1-22.5,22.6-22.5c6,0,11.7,2.4,16,6.6
|
||||
c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5c0,0,0,0,0,0h0c-3.8,0-7.5-0.9-10.8-2.7L1.3,46.8z"/>
|
||||
<path class="st0" d="M1.3,47.4c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.2-0.2-0.4-0.2-0.6l3.1-11.4c-1.9-3.5-3-7.4-3-11.4
|
||||
c0-12.8,10.4-23.1,23.2-23.1c6.2,0,12,2.4,16.4,6.8c4.4,4.4,6.8,10.2,6.8,16.4C47.2,36.6,36.9,47,24.1,47c-3.8,0-7.5-0.9-10.9-2.7
|
||||
L1.5,47.4C1.4,47.4,1.4,47.4,1.3,47.4z"/>
|
||||
<path class="st1" d="M24.1,1.3c6,0,11.7,2.4,16,6.6c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5h0c-3.8,0-7.5-0.9-10.8-2.7
|
||||
l-12,3.1l3.2-11.7c-2-3.4-3-7.3-3-11.3C1.5,11.4,11.7,1.3,24.1,1.3 M24.1,46.4L24.1,46.4L24.1,46.4 M24.1,46.4L24.1,46.4L24.1,46.4
|
||||
M24.1,0.1L24.1,0.1C11,0.1,0.4,10.8,0.3,23.9c0,4,1,7.9,2.9,11.4L0.2,46.5c-0.1,0.4,0,0.8,0.3,1.1C0.7,47.9,1,48,1.3,48
|
||||
c0.1,0,0.2,0,0.3,0l11.5-3c3.4,1.7,7.1,2.7,10.9,2.7c13.1,0,23.7-10.6,23.8-23.7c0-6.3-2.5-12.3-6.9-16.8
|
||||
C36.4,2.6,30.4,0.1,24.1,0.1L24.1,0.1z"/>
|
||||
<path class="st2" d="M37.4,10.6c-3.5-3.5-8.2-5.5-13.3-5.5c-10.3,0-18.8,8.4-18.8,18.7c0,3.5,1,7,2.9,10l0.4,0.7l-1.9,6.9l7.1-1.9
|
||||
l0.7,0.4c2.9,1.7,6.2,2.6,9.5,2.6h0c10.3,0,18.7-8.4,18.7-18.7C42.8,18.9,40.9,14.2,37.4,10.6z"/>
|
||||
<path class="st0" d="M18.5,14.4c-0.4-0.9-0.9-1-1.3-1c-0.3,0-0.7,0-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.8
|
||||
s3.9,6.2,9.6,8.5c4.8,1.9,5.7,1.5,6.8,1.4c1-0.1,3.3-1.4,3.8-2.7c0.5-1.3,0.5-2.4,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
|
||||
c-0.6-0.3-3.3-1.6-3.9-1.8s-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.8-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.5-2.8
|
||||
c-1.7-1.5-2.8-3.3-3.1-3.9c-0.3-0.6,0-0.9,0.2-1.2c0.3-0.3,0.6-0.7,0.8-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
|
||||
C20.1,18.3,19,15.5,18.5,14.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
public/images/icons/vuesax/bold/pinterest.svg
Normal file
3
public/images/icons/vuesax/bold/pinterest.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12C2 16.25 4.67 19.9 8.44 21.34C8.35 20.56 8.28 19.36 8.47 18.5C8.65 17.72 9.63 13.6 9.63 13.6C9.63 13.6 9.33 13 9.33 12.09C9.33 10.66 10.16 9.6 11.2 9.6C12.08 9.6 12.5 10.26 12.5 11.05C12.5 11.93 11.93 13.26 11.63 14.5C11.39 15.53 12.15 16.38 13.16 16.38C14.99 16.38 16.4 14.45 16.4 11.66C16.4 9.19 14.62 7.46 12.08 7.46C9.14 7.46 7.42 9.66 7.42 11.94C7.42 12.82 7.76 13.77 8.19 14.29C8.27 14.39 8.28 14.48 8.26 14.58C8.19 14.86 8.04 15.46 8.01 15.59C7.97 15.77 7.87 15.81 7.68 15.72C6.44 15.14 5.67 13.34 5.67 11.9C5.67 8.75 7.95 5.86 12.24 5.86C15.68 5.86 18.36 8.31 18.36 11.62C18.36 15.07 16.19 17.85 13.16 17.85C12.13 17.85 11.16 17.31 10.83 16.68C10.83 16.68 10.32 18.65 10.2 19.1C9.97 19.97 9.35 21.07 8.94 21.74C9.9 22.04 10.93 22.2 12 22.2C17.52 22.2 22 17.72 22 12.2C22 6.68 17.52 2 12 2Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
@@ -8,7 +8,7 @@
|
||||
"start_url": "/?source=pwa&lang=fa",
|
||||
"id": "modstagram-pwa",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui", "browser"],
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui", "browser"],
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
|
||||
@@ -106,7 +106,7 @@ function Colors() {
|
||||
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -116,7 +116,7 @@ function Colors() {
|
||||
{t("auth.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -98,7 +98,7 @@ function Sizes() {
|
||||
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
@@ -115,7 +115,7 @@ function Sizes() {
|
||||
{t("auth.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -132,7 +132,7 @@ function Sizes() {
|
||||
{t("auth.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -174,7 +174,7 @@ function Sizes() {
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
className={`p-2 py-1 rounded-2xl border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -524,9 +524,11 @@ export default function CourseDetail({
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academy.course.redirectedToPayment"));
|
||||
|
||||
// window.open بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپآپبلاک میشه —
|
||||
// چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم
|
||||
// مطمئنتره، هم با بقیهی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی میمونه
|
||||
window.location.href = paymentUrl;
|
||||
return;
|
||||
} else {
|
||||
console.error("ساختار پاسخ نامعتبر:", response);
|
||||
toast.error(
|
||||
|
||||
@@ -82,7 +82,9 @@ function FailedProject() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import Header from "@/components/main/Header";
|
||||
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
|
||||
|
||||
export async function generateMetadata() {
|
||||
|
||||
return generateSeoPageMetadata("newProject", {
|
||||
|
||||
index: false,
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { generateSeoPageMetadata } from "@/lib/i18n/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
return generateSeoPageMetadata("newProject", {
|
||||
index: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
<LocalePageShell>{children}</LocalePageShell>
|
||||
{/* واقعاً توی /new-project هستیم، نه /projects — قبلاً اینجا "/projects" هاردکد
|
||||
شده بود که باعث میشد تبِ پروژهها فکر کنه از قبل توی روتِ خودشه (isExactRoot)
|
||||
و اولین کلیک روی اون تب preventDefault بشه بدون navigate (کاربر مجبور میشد
|
||||
دوبار بزنه). isActive توی TabNavigation.tsx از قبل شرطِ جداگانه برای
|
||||
"/new-project" داره، پس هایلایتِ تب دستنخورده میمونه */}
|
||||
<TabNavigation currentPage="/new-project" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -144,7 +143,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -66,7 +66,9 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -56,7 +56,9 @@ function FailedBillboard() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
@@ -80,7 +82,9 @@ function FailedBillboard() {
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -9,7 +9,6 @@ import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -25,7 +24,6 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { userId } = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(
|
||||
@@ -67,10 +65,12 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
"/gifts/initiate",
|
||||
{ receiverId: userId, amount: finalAmount, message }
|
||||
);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
window.location.href = response.paymentUrl;
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
|
||||
@@ -749,22 +749,30 @@ select {
|
||||
--btn-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
.btn-modern {
|
||||
border: 1px solid var(--btn-border-color);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btn-modern:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.dark .btn-modern {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.dark .btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
|
||||
/* داخلِ @layer components (برخلافِ btn-modern--primary/--selected زیر که عمداً
|
||||
بیرونِ لایهست) تا رنگِ بوردرِ اینجا (خاکستریِ پیشفرض) رویِ کلاسهای Tailwind
|
||||
مثلِ border-[#FC8EAC] که کامپوننتها (مثلِ btnPrimary) پاس میدن سوار نشه —
|
||||
قبلاً چون این رول بیرونِ هر @layer بود، همیشه برنده میشد و باعث میشد بوردرِ
|
||||
دکمههای انتخابشده (مثلاً btnPrimary توی تنظیماتِ فروشگاه) هیچوقت صورتی نشه،
|
||||
با اینکه رنگِ متنش درست صورتی میشد (چون فقط border ست شده بود، نه color) */
|
||||
@layer components {
|
||||
.btn-modern {
|
||||
border: 1px solid var(--btn-border-color);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btn-modern:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.dark .btn-modern {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.dark .btn-modern:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-modern--primary {
|
||||
@@ -776,14 +784,16 @@ select {
|
||||
background: var(--btn-accent-hover);
|
||||
}
|
||||
|
||||
/* عینِ btnPrimary (دکمه «اعمال فیلتر»): تینتِ کمرنگِ رنگِ اصلی + بوردر/متنِ رنگِ
|
||||
اصلی، نه پرشدنِ توپر + متنِ سفید — چون این کلاس خارج از @layer تعریف شده،
|
||||
رویِ هر text-white که کنارش پاس داده بشه اولویت داره (لایههای CSS Tailwind v4) */
|
||||
.btn-modern--selected {
|
||||
background: var(--btn-accent);
|
||||
color: var(--btn-accent-text);
|
||||
background: rgba(252, 142, 172, 0.1);
|
||||
color: var(--btn-accent);
|
||||
border-color: var(--btn-accent);
|
||||
}
|
||||
.btn-modern--selected:hover:not(:disabled) {
|
||||
background: var(--btn-accent-hover);
|
||||
border-color: var(--btn-accent-hover);
|
||||
background: rgba(252, 142, 172, 0.16);
|
||||
}
|
||||
|
||||
.glass-modal-overlay {
|
||||
|
||||
@@ -158,13 +158,14 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
setHasUsedFreeOffer(true);
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paymentUrl) {
|
||||
window.open(data.paymentUrl, "_blank", "noopener,noreferrer");
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.open(
|
||||
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
|
||||
@@ -297,9 +297,11 @@ function Chats() {
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{isOnline ? onlineLabel : item.last_online || offlineLabel}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{/* طبقِ خواستهی کاربر فعلاً کامنت شد — این متن همیشه ثابت
|
||||
نمایش داده میشد (نه پیشنمایشِ واقعیِ آخرین پیام) */}
|
||||
{/* <span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{E2EE_LOCKED_PREVIEW}
|
||||
</span>
|
||||
</span> */}
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
|
||||
@@ -152,7 +152,6 @@ function ArchivePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -66,8 +66,8 @@ function BlockedUsersPage() {
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -103,7 +103,7 @@ function Colors() {
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -113,7 +113,7 @@ function Colors() {
|
||||
{t("settings.edit.colors.eyeColor")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -78,7 +78,7 @@ function Sizes() {
|
||||
<>
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 1
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
|
||||
@@ -89,7 +89,7 @@ function Sizes() {
|
||||
{t("settings.edit.sizes.height")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 2
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -100,7 +100,7 @@ function Sizes() {
|
||||
{t("settings.edit.sizes.weight")}
|
||||
</button>
|
||||
<button
|
||||
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
|
||||
selectedButton === 3
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
@@ -136,7 +136,7 @@ function Sizes() {
|
||||
{sizes.map((sizeOption) => (
|
||||
<button
|
||||
key={sizeOption}
|
||||
className={`p-2 py-1 rounded-full border text-sm full ${
|
||||
className={`p-2 py-1 rounded-2xl border text-sm full ${
|
||||
size === sizeOption
|
||||
? "btn-modern--selected text-white"
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
|
||||
|
||||
@@ -103,33 +103,33 @@ export default function Financial() {
|
||||
key={item._id}
|
||||
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
||||
<div className="font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div className="min-w-0 font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
|
||||
{getTransactionType(item.type)}
|
||||
{item.project_title && (
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<span className="truncate text-sm text-neutral-500 dark:text-neutral-400">
|
||||
({item.project_title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div className="font-semibold text-lg text-neutral-900 dark:text-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 font-semibold text-lg text-neutral-900 dark:text-white">
|
||||
{t("settings.financial.amountPaid")}:{" "}
|
||||
{Number(item.amount).toLocaleString(amountLocale)}{" "}
|
||||
{t("settings.toman")}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("settings.status")}:
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium px-4 py-1.5 rounded-full text-sm ${getStatusClass(
|
||||
className={`font-medium px-4 py-1.5 rounded-full text-sm whitespace-nowrap ${getStatusClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
|
||||
@@ -8,8 +8,9 @@ import Header from "@/components/main/Header";
|
||||
import ShopListCard from "@/components/shops/ShopListCard";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MyShop = {
|
||||
@@ -28,15 +29,19 @@ export default function ShopSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shops: MyShop[] }>("GET", "/shops/mine")
|
||||
.then((res) => setMyShops(res?.shops || []))
|
||||
.catch(() => setMyShops([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// طبق خواستهی کاربر: بعدِ برگشت از ویرایشِ فروشگاه بدونِ رفرشِ دستی تغییرات دیده
|
||||
// بشن — قبلاً با useEffect(..., []) فقط یهبار موقعِ اولین mount فچ میشد؛ عینِ
|
||||
// useInfiniteScroll (لیستِ کالاها) با refetchOnMount:"always" هر بار برگردیم به
|
||||
// این صفحه دوباره فچ میشه
|
||||
const { data } = useQuery({
|
||||
queryKey: ["shops-mine"],
|
||||
queryFn: () => request<{ shops: MyShop[] }>("GET", "/shops/mine"),
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const myShops = data?.shops ?? null;
|
||||
|
||||
const filteredShops = useMemo(() => {
|
||||
if (!myShops) return [];
|
||||
|
||||
@@ -17,7 +17,7 @@ import ShopCartTab from "@/components/shops/ShopCartTab";
|
||||
import ShopFavoritesTab from "@/components/shops/ShopFavoritesTab";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { toggleBtnClass, filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
@@ -164,13 +164,13 @@ export default function ShopSettingsPage() {
|
||||
</div>
|
||||
<div className="mx-auto mt-4 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_SELLER), "h-9")}
|
||||
className={cn(filter === FILTER_SELLER ? btnPrimary : btnDefault, "h-10 text-xs")}
|
||||
onClick={() => setFilter(FILTER_SELLER)}
|
||||
>
|
||||
{t("shops.sellerTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_BUYER), "h-9")}
|
||||
className={cn(filter === FILTER_BUYER ? btnPrimary : btnDefault, "h-10 text-xs")}
|
||||
onClick={() => setFilter(FILTER_BUYER)}
|
||||
>
|
||||
{t("shops.buyerTab")}
|
||||
@@ -184,7 +184,7 @@ export default function ShopSettingsPage() {
|
||||
key={tabId}
|
||||
type="button"
|
||||
onClick={() => setSellerTab(tabId)}
|
||||
className={filterChipClass(sellerTab === tabId, "shrink-0")}
|
||||
className={cn(sellerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
|
||||
>
|
||||
{t(`shops.sellerTabs.${tabId}`)}
|
||||
</button>
|
||||
@@ -199,7 +199,7 @@ export default function ShopSettingsPage() {
|
||||
key={tabId}
|
||||
type="button"
|
||||
onClick={() => setBuyerTab(tabId)}
|
||||
className={filterChipClass(buyerTab === tabId, "shrink-0")}
|
||||
className={cn(buyerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
|
||||
>
|
||||
{t(`shops.buyerTabs.${tabId}`)}
|
||||
</button>
|
||||
|
||||
@@ -8,7 +8,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,7 +31,6 @@ type WalletSummary = {
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
@@ -88,7 +87,9 @@ export default function WalletSettingsPage() {
|
||||
{ amount }
|
||||
);
|
||||
if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
|
||||
@@ -18,15 +18,18 @@ import {
|
||||
removeProjectDraft,
|
||||
} from "@/lib/projectDrafts";
|
||||
|
||||
// رنگِ pastel هرکدوم حذف شد: طبق خواستهی کاربر، این چیپها باید استایلِ استانداردِ اپ
|
||||
// (variant="primary" یِ RoundedButton، عینِ بقیهی دکمههای انتخاب) رو داشته باشن، نه
|
||||
// رنگِ مخصوصِ هر وضعیت
|
||||
const STATUS_OPTIONS = [
|
||||
{ key: "received", value: "دریافتی", color: "#B89FFF" },
|
||||
{ key: "unpaid", value: "پرداخت نشده", color: "#ddd" },
|
||||
{ key: "reviewing", value: "در دست بررسی", color: "#E0D891" },
|
||||
{ key: "sent", value: "ارسال شده", color: "#ADF5FF" },
|
||||
{ key: "published", value: "منتشر شده", color: "#ADF5FF" },
|
||||
{ key: "inProgress", value: "در دست اقدام", color: "#E0D891" },
|
||||
{ key: "completed", value: "اتمام پروژه", color: "#B7FFB6" },
|
||||
{ key: "cancelled", value: "کنسل شده", color: "#FFC5C5" },
|
||||
{ key: "received", value: "دریافتی" },
|
||||
{ key: "unpaid", value: "پرداخت نشده" },
|
||||
{ key: "reviewing", value: "در دست بررسی" },
|
||||
{ key: "sent", value: "ارسال شده" },
|
||||
{ key: "published", value: "منتشر شده" },
|
||||
{ key: "inProgress", value: "در دست اقدام" },
|
||||
{ key: "completed", value: "اتمام پروژه" },
|
||||
{ key: "cancelled", value: "کنسل شده" },
|
||||
] as const;
|
||||
|
||||
const Workroom = () => {
|
||||
@@ -76,38 +79,24 @@ const Workroom = () => {
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
<div className="grid grid-cols-3 gap-2 px-4 text-sm">
|
||||
<RoundedButton
|
||||
variant={showDrafts ? "primary" : "default"}
|
||||
onClick={() => {
|
||||
setShowDrafts(true);
|
||||
setStatusFilter("");
|
||||
}}
|
||||
className={`p-1 text-xs sm:p-2 md:text-sm ${
|
||||
showDrafts ? "text-primary-dark" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: showDrafts ? "#FFD6E7" : "",
|
||||
borderColor: showDrafts ? "#FFD6E7" : "",
|
||||
}}
|
||||
className="p-1 text-xs sm:p-2 md:text-sm"
|
||||
>
|
||||
{t("settings.workroom.drafts")}
|
||||
</RoundedButton>
|
||||
{STATUS_OPTIONS.map(({ key, value, color }) => (
|
||||
{STATUS_OPTIONS.map(({ key, value }) => (
|
||||
<RoundedButton
|
||||
key={value}
|
||||
variant={!showDrafts && statusFilter === value ? "primary" : "default"}
|
||||
onClick={() => {
|
||||
setShowDrafts(false);
|
||||
setStatusFilter(value);
|
||||
}}
|
||||
className={`p-1 text-xs sm:p-2 md:text-sm ${
|
||||
!showDrafts && statusFilter === value
|
||||
? " text-primary-dark"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
!showDrafts && statusFilter === value ? color : "",
|
||||
borderColor:
|
||||
!showDrafts && statusFilter === value ? color : "",
|
||||
}}
|
||||
className="p-1 text-xs sm:p-2 md:text-sm"
|
||||
>
|
||||
{t(`settings.workroom.statuses.${key}`)}
|
||||
</RoundedButton>
|
||||
|
||||
@@ -6,8 +6,12 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import SendShopModal from "@/components/shops/SendShopModal";
|
||||
import ShopProductListRow from "@/components/shops/ShopProductListRow";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import VerificationBadge, {
|
||||
resolveShopVerificationTier,
|
||||
} from "@/components/main/VerificationBadge";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -25,10 +29,12 @@ type ShopHeader = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
owner?: { verify_badge?: string | null } | null;
|
||||
};
|
||||
|
||||
export default function ShopProductsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
@@ -37,6 +43,7 @@ export default function ShopProductsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [onlyInStock, setOnlyInStock] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: ShopHeader }>("GET", `/shops/${shopId}`)
|
||||
@@ -73,43 +80,66 @@ export default function ShopProductsPage() {
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
{/* طبق خواستهی کاربر: لوگو راست (فرزند اول → فیزیکی راست زیرِ RTL)، بعدش نامِ
|
||||
فروشگاه (فرزند دوم، flex-1 باقی فضا رو پر میکنه و کلیکپذیره مثلِ لوگو)،
|
||||
و اشتراکگذاری سمتِ چپِ کادر و روبروی نام (فرزند سوم/آخر → فیزیکی چپترین) —
|
||||
عینِ اندروید (ShopProductsScreen.tsx) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{shopId && (
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
aria-label={t("shops.viewPublicProfile")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
<BoldIcon name="share" size={18} tinted />
|
||||
</button>
|
||||
{shopId && (
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
aria-label={t("shops.viewPublicProfile")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
{shop?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={16}
|
||||
height={16}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{shop?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={16}
|
||||
height={16}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span className="truncate text-sm font-semibold">{shop?.name}</span>
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-1 truncate text-sm font-semibold",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop?.name}
|
||||
<VerificationBadge
|
||||
verifyBadge={resolveShopVerificationTier(shop?.owner?.verify_badge)}
|
||||
size={13}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSendModal(true)}
|
||||
aria-label={t("shops.sendShopTitle")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="send-2" size={18} tinted />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="share" size={18} tinted />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PageTitle>{t("shops.myProductsTitle")}</PageTitle>
|
||||
@@ -197,6 +227,15 @@ export default function ShopProductsPage() {
|
||||
shopLogo={shop.logo}
|
||||
/>
|
||||
)}
|
||||
{shop && (
|
||||
<SendShopModal
|
||||
open={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
logo={shop.logo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import SendProductModal from "@/components/shops/SendProductModal";
|
||||
import DiscountBadge from "@/components/ui/DiscountBadge";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { saveProductWizardId } from "@/hooks/useProductWizardId";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
@@ -52,6 +54,7 @@ export default function ProductManagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
@@ -197,6 +200,23 @@ export default function ProductManagePage() {
|
||||
router.push(`/shops/${shopId}/products/new/name?edit=1`);
|
||||
};
|
||||
|
||||
// طبق خواستهی کاربر: اشتراکگذاری لینکِ خودِ کالا — عینِ ListingDetailClient.tsx
|
||||
const shareProduct = async () => {
|
||||
if (!listing?._id) return;
|
||||
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
|
||||
if (typeof navigator !== "undefined" && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ url: productUrl, title: listing.title });
|
||||
return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok = await copyTextToClipboard(productUrl);
|
||||
if (ok) toast.success(t("shops.share.copied"));
|
||||
else toast.error(t("shops.share.copyFailed"));
|
||||
};
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const images = listing.images || [];
|
||||
@@ -213,33 +233,60 @@ export default function ProductManagePage() {
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex w-full">
|
||||
<BackButton />
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
{/* طبق خواستهی کاربر: نام+لوگوی فروشگاه راست (فرزند اول)، عینِ اندروید، و
|
||||
اشتراکگذاری روبروی نام سمت چپ (فرزند آخر) */}
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSendModal(true)}
|
||||
aria-label={t("shops.sendProductTitle")}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="send-2" size={18} tinted />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void shareProduct()}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="share" size={18} tinted />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* گردی گوشههای کادر ۱۶px (rounded-2xl) عینِ اندروید (ProductDetailScreen.tsx:
|
||||
card.borderRadius=16) — قبلاً rounded-3xl (۲۴px) بود */}
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
{/* عینِ اندروید: عنوان راست (فرزند اول)، بجِ تخفیف چپ (فرزند آخر) — قبلاً
|
||||
برعکس بود (بج قبل از عنوان) */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
|
||||
<h1 className="text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
@@ -259,7 +306,6 @@ export default function ProductManagePage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -429,6 +475,14 @@ export default function ProductManagePage() {
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{listing ? (
|
||||
<SendProductModal
|
||||
open={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
listing={listing}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,16 +36,21 @@ function ProductImagesPage() {
|
||||
|
||||
const [images, setImages] = useState<PickedImage[]>([]);
|
||||
const [primaryIndex, setPrimaryIndex] = useState(0);
|
||||
const [isPinterest, setIsPinterest] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listingId) return;
|
||||
request<{ listing: { images: string[]; primaryImageIndex: number } }>(
|
||||
"GET",
|
||||
`/shop-products/${listingId}`
|
||||
).then((res) => {
|
||||
request<{
|
||||
listing: {
|
||||
images: string[];
|
||||
primaryImageIndex: number;
|
||||
is_pinterest?: boolean;
|
||||
};
|
||||
}>("GET", `/shop-products/${listingId}`).then((res) => {
|
||||
if (!res?.listing) return;
|
||||
setImages((res.listing.images || []).map((path) => ({ type: "existing", path })));
|
||||
setPrimaryIndex(res.listing.primaryImageIndex || 0);
|
||||
setIsPinterest(Boolean(res.listing.is_pinterest));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listingId]);
|
||||
@@ -83,6 +88,7 @@ function ProductImagesPage() {
|
||||
newFiles.forEach((img) => formData.append("images", img.file, img.file.name));
|
||||
formData.append("existing_images", JSON.stringify(existingImages));
|
||||
formData.append("primaryImageIndex", String(primaryIndex));
|
||||
formData.append("is_pinterest", String(isPinterest));
|
||||
|
||||
try {
|
||||
await request("PATCH", `/shop-products/${listingId}`, formData);
|
||||
@@ -172,6 +178,34 @@ function ProductImagesPage() {
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPinterest((v) => !v)}
|
||||
className="mt-4 flex w-full max-w-sm items-center gap-2.5 rounded-2xl border border-neutral-200 bg-neutral-50 px-3.5 py-3 text-right dark:border-neutral-700 dark:bg-neutral-900"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2",
|
||||
isPinterest
|
||||
? "border-[#E60023] bg-[#E60023]"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{isPinterest ? (
|
||||
<BoldIcon name="tick-square" size={12} tinted className="text-white" />
|
||||
) : null}
|
||||
</span>
|
||||
<BoldIcon name="pinterest" size={18} tinted className="text-[#E60023]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("shops.pinterestProductLabel")}
|
||||
</span>
|
||||
<span className="block text-[11px] text-neutral-500">
|
||||
{t("shops.pinterestProductHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter onSkip={goNext} skipDisabled={loading}>
|
||||
<AuthNextButton
|
||||
|
||||
@@ -138,7 +138,11 @@ function ProductNamePage() {
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full max-w-sm justify-start">
|
||||
<BackButton />
|
||||
{/* پیشفرضِ router.back() اینجا کاربر رو برمیگردوند به هرجایی که ازش وارد این
|
||||
ویزارد شده — که برای ویرایش، صفحهی نمایشِ کالا
|
||||
(shops/[shopId]/products/[listingId]) بود، نه لیستِ کالاها. طبقِ خواستهی
|
||||
کاربر همیشه صریحاً میره به لیستِ کالاهای همین فروشگاه */}
|
||||
<BackButton href={`/shops/${shopId}`} />
|
||||
</div>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>
|
||||
|
||||
@@ -297,6 +297,7 @@ function ProductVariantsPage() {
|
||||
loading={loading}
|
||||
disabled={loading || rows.length === 0}
|
||||
onClick={handleSubmit}
|
||||
className="border-[#ef4444] bg-[#ef4444] text-white hover:bg-[#ef4444]/90 dark:bg-[#ef4444] dark:hover:bg-[#ef4444]/90"
|
||||
>
|
||||
{t("shops.saveProduct")}
|
||||
</AuthNextButton>
|
||||
|
||||
@@ -6,7 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -34,7 +34,6 @@ type OrderDetail = {
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ orderId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
@@ -54,7 +53,10 @@ export default function CheckoutPage() {
|
||||
{ orderId: params.orderId }
|
||||
);
|
||||
if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمینشدهای نداره —
|
||||
// window.location.href مطمئنترین راهه (خصوصاً سافاری) و با بقیهی مسیرهای
|
||||
// پرداختِ این اپ (booking، آکادمی) هم یکی میمونه
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
@@ -94,7 +96,6 @@ export default function CheckoutPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import ShopProductGrid from "@/components/shops/ShopProductGrid";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ALL_ID = "__all__";
|
||||
const PINTEREST_ID = "__pinterest__";
|
||||
|
||||
type ShopCategory = { category: string; sub_categories?: unknown[] };
|
||||
|
||||
export default function ShopDiscoverPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [activeFilter, setActiveFilter] = useState<string>(ALL_ID);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ categories?: ShopCategory[] }>("GET", "/shop-categories", null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) =>
|
||||
setCategories(
|
||||
(res?.categories ?? []).map((c) => c.category).filter(Boolean)
|
||||
)
|
||||
)
|
||||
.catch(() => setCategories([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const chips = useMemo(() => [ALL_ID, PINTEREST_ID, ...categories], [categories]);
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products/discover",
|
||||
queryKey: ["shop-products-discover"],
|
||||
queryKey: ["shop-products-discover", activeFilter],
|
||||
params:
|
||||
activeFilter === PINTEREST_ID
|
||||
? { pinterest: "true" }
|
||||
: activeFilter === ALL_ID
|
||||
? {}
|
||||
: { category: activeFilter },
|
||||
});
|
||||
|
||||
const listings: IShopProductListing[] =
|
||||
@@ -27,6 +60,40 @@ export default function ShopDiscoverPage() {
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{t("shops.discoverTitle")}</PageTitle>
|
||||
|
||||
<div className="mb-3 flex items-center gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{chips.map((c) => {
|
||||
const active = activeFilter === c;
|
||||
const isPinterest = c === PINTEREST_ID;
|
||||
const label =
|
||||
c === ALL_ID
|
||||
? t("shops.allFilter")
|
||||
: isPinterest
|
||||
? t("shops.pinterestFilter")
|
||||
: c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setActiveFilter(c)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-2xl border px-3.5 py-2 text-sm font-medium transition",
|
||||
active
|
||||
? isPinterest
|
||||
? "border-[#E60023] bg-[#E60023]/10 text-[#E60023]"
|
||||
: "border-pink-400 bg-pink-50 text-pink-600 dark:bg-pink-950/30"
|
||||
: "border-neutral-200 text-neutral-700 dark:border-neutral-700 dark:text-neutral-300"
|
||||
)}
|
||||
>
|
||||
{isPinterest ? (
|
||||
<BoldIcon name="pinterest" size={14} tinted />
|
||||
) : null}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ShopProductGrid
|
||||
listings={listings}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -5,12 +5,16 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
|
||||
import SendProductModal from "@/components/shops/SendProductModal";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import DiscountBadge from "@/components/ui/DiscountBadge";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { btnDefault, btnPrimary } from "@/lib/ui/buttonStyles";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
@@ -50,8 +54,10 @@ export default function ListingDetailClient() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
@@ -94,6 +100,24 @@ export default function ListingDetailClient() {
|
||||
const shopInfo: ShopHeader | null =
|
||||
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
|
||||
|
||||
// طبق خواستهی کاربر: اشتراکگذاری لینکِ خودِ کالا، نه فروشگاه — عینِ shareProduct
|
||||
// توی ProductDetailScreen.tsx اندروید (Web Share API با fallback به کپیِ لینک)
|
||||
const shareProduct = async () => {
|
||||
if (!listing?._id) return;
|
||||
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
|
||||
if (typeof navigator !== "undefined" && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ url: productUrl, title: listing.title });
|
||||
return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok = await copyTextToClipboard(productUrl);
|
||||
if (ok) toast.success(t("shops.share.copied"));
|
||||
else toast.error(t("shops.share.copyFailed"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!listing?._id) return;
|
||||
const token = Cookies.get("token") || "";
|
||||
@@ -164,6 +188,10 @@ export default function ListingDetailClient() {
|
||||
}, [matchedVariant?._id]);
|
||||
|
||||
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
|
||||
// هزینهی روشِ ارسالِ انتخابشده — قبلاً هیچجا خونده نمیشد، پس انتخابِ روشِ ارسال
|
||||
// هیچ تاثیری روی قیمتِ نمایشدادهشده نداشت (فقط موقعِ ثبتِ سفارش/handleBuy چک میشد)
|
||||
const shippingCost =
|
||||
enabledShippingMethods.find((m) => m.method === shippingMethod)?.cost || 0;
|
||||
|
||||
const categoryLine = listing
|
||||
? [listing.category, listing.sub_category, listing.sub_sub_category]
|
||||
@@ -256,34 +284,58 @@ export default function ListingDetailClient() {
|
||||
<div className="flex w-full">
|
||||
<BackButton />
|
||||
</div>
|
||||
{/* طبق خواستهی کاربر: دکمهی اشتراکگذاریِ خودِ کالا (نه فروشگاه) روبروی نام
|
||||
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
|
||||
{shopInfo?._id ? (
|
||||
<Link
|
||||
href={`/shops/profile/${shopInfo._id}`}
|
||||
className="flex items-center justify-end gap-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
</Link>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Link
|
||||
href={`/shops/profile/${shopInfo._id}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requireAuth()) return;
|
||||
setShowSendModal(true);
|
||||
}}
|
||||
aria-label={t("shops.sendProductTitle")}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="send-2" size={18} tinted />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void shareProduct()}
|
||||
aria-label={t("shops.share.title")}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="share" size={18} tinted />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
{/* گردی گوشههای کادر ۱۶px (rounded-2xl) عینِ اندروید — قبلاً rounded-3xl (۲۴px) بود */}
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
@@ -310,7 +362,6 @@ export default function ListingDetailClient() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
@@ -432,14 +483,14 @@ export default function ListingDetailClient() {
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{/* عین ProductDetailScreen.tsx اندروید: دکمهی ثبت خرید با colors.success (#387E65)،
|
||||
{/* عین ProductDetailScreen.tsx اندروید: دکمهی ثبت خرید با colors.danger (#ef4444)،
|
||||
و اسپینر تعداد یک پیل بوردردار خنثی با دکمههای +/- متنی، نه دو دکمهی دایرهای رنگی */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-2xl bg-[#387E65] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
className="flex-1 rounded-2xl bg-[#ef4444] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
@@ -471,15 +522,15 @@ export default function ListingDetailClient() {
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{(matchedVariant.discount_price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
@@ -487,19 +538,19 @@ export default function ListingDetailClient() {
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
{/* عین ProductDetailScreen.tsx اندروید: سبد خرید با colors.info (#17337B) توپر،
|
||||
علاقهمندی فقط حاشیهدارِ همون رنگ (نه پرشده) */}
|
||||
{/* طبق خواستهی کاربر: همون رنگ/استایلِ دکمههای اعمال/پاککردنِ فیلترها —
|
||||
سبد خرید مثل «اعمال» (btnPrimary)، علاقهمندی مثل «پاک کردن» (btnDefault) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-2xl bg-[#17337B] py-3 text-sm font-bold text-white"
|
||||
className={cn(btnPrimary, "flex-1 py-3 text-sm")}
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-2xl border border-[#17337B] py-3 text-sm font-bold text-[#17337B]"
|
||||
className={cn(btnDefault, "flex-1 py-3 text-sm")}
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
@@ -537,6 +588,12 @@ export default function ListingDetailClient() {
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
|
||||
<SendProductModal
|
||||
open={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
listing={listing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,8 +129,62 @@ function ShopLocationPage() {
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">{t("shops.locationTitle")}</span>
|
||||
|
||||
{/* طبق خواستهی کاربر: استان/شهر همیشه باز و اجباریان (چه حضوری داشته باشه چه
|
||||
نه)، فقط نقشه/محله/آدرسِ دقیق مخصوصِ حالتِ حضوریه و مخفی میشن؛ تیکِ «فروشگاه
|
||||
حضوری ندارم» هم رفت زیرِ آدرس — عینِ اندروید (NewShopLocationScreen.tsx) */}
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
<label className="mb-2 flex items-center gap-2">
|
||||
<CountryProvinceCitySelect
|
||||
value={geoSelection}
|
||||
onChange={setGeoSelection}
|
||||
lockToUserCountry
|
||||
/>
|
||||
|
||||
{hasPhysicalLocation && (
|
||||
<>
|
||||
<div className="mt-2 w-full overflow-hidden rounded-2xl">
|
||||
<Map
|
||||
style={{ height: "220px" }}
|
||||
initialViewState={{
|
||||
longitude: marker?.lng || 51.375433528216654,
|
||||
latitude: marker?.lat || 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{marker && (
|
||||
<Marker latitude={marker.lat} longitude={marker.lng}>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src="/images/icons/location.svg"
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
</div>
|
||||
|
||||
<RoundedInput
|
||||
type="text"
|
||||
className="mt-2 text-xs"
|
||||
placeholder={t("shops.neighbourhoodPlaceholder")}
|
||||
value={neighbourhood}
|
||||
onChange={(e) => setNeighbourhood(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
placeholder={t("shops.addressPlaceholder")}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-125"
|
||||
@@ -139,53 +193,6 @@ function ShopLocationPage() {
|
||||
/>
|
||||
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
|
||||
</label>
|
||||
|
||||
<CountryProvinceCitySelect
|
||||
value={geoSelection}
|
||||
onChange={setGeoSelection}
|
||||
lockToUserCountry
|
||||
/>
|
||||
|
||||
<div className="mt-2 w-full overflow-hidden rounded-2xl">
|
||||
<Map
|
||||
style={{ height: "220px" }}
|
||||
initialViewState={{
|
||||
longitude: marker?.lng || 51.375433528216654,
|
||||
latitude: marker?.lat || 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{marker && (
|
||||
<Marker latitude={marker.lat} longitude={marker.lng}>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src="/images/icons/location.svg"
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
</div>
|
||||
|
||||
<RoundedInput
|
||||
type="text"
|
||||
className="mt-2 text-xs"
|
||||
placeholder={t("shops.neighbourhoodPlaceholder")}
|
||||
value={neighbourhood}
|
||||
onChange={(e) => setNeighbourhood(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
placeholder={t("shops.addressPlaceholder")}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
|
||||
@@ -46,8 +46,17 @@ function ShopLogoPage() {
|
||||
const goNext = () => router.push("/shops/new/shipping");
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!logoFile || !shopId) {
|
||||
toast.error(t("shops.logoRequired"));
|
||||
if (!shopId) return;
|
||||
// اگه کاربر فایلِ جدید انتخاب نکرده ولی از قبل لوگو داره (ویرایشِ فروشگاهِ
|
||||
// موجود، logoPreview از GET /shops/:id پر شده)، نباید پیامِ «لطفاً لوگو رو
|
||||
// انتخاب کنید» نشون داده بشه — قبلاً اینجا فقط logoFile چک میشد نه
|
||||
// logoPreview، پس با وجودِ لوگوی از قبلثبتشده هم گیر میکرد
|
||||
if (!logoFile) {
|
||||
if (logoPreview) {
|
||||
goNext();
|
||||
} else {
|
||||
toast.error(t("shops.logoRequired"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -356,7 +356,6 @@ export default function OrderDetailPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,9 @@ export default function ShopPaymentFailedPage() {
|
||||
{ orderId }
|
||||
);
|
||||
if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
} catch {
|
||||
// toast already shown by the axios interceptor
|
||||
|
||||
@@ -85,12 +85,11 @@ export default function ProductComparisonPage() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
unoptimized
|
||||
/>
|
||||
{discountPercent != null && (
|
||||
<DiscountBadge
|
||||
percent={discountPercent}
|
||||
className="absolute right-1 top-1"
|
||||
className="absolute left-1 top-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,12 @@ import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import SendShopModal from "@/components/shops/SendShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import VerificationBadge, {
|
||||
resolveShopVerificationTier,
|
||||
} from "@/components/main/VerificationBadge";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
@@ -52,6 +56,7 @@ type PublicShop = {
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
verify_badge?: string | null;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
@@ -94,6 +99,7 @@ export default function ShopProfileClient() {
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
@@ -205,45 +211,52 @@ export default function ShopProfileClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
{/* طبق خواستهی کاربر: توضیحاتِ فروشگاه درست زیرِ نام، و برچسبِ نوعِ فروش
|
||||
(«حضوری»/«آنلاین») درست زیرِ ریتوامتیاز — یعنی دو ستونِ عمودی کنارِ هم
|
||||
بهجای یک ردیفِ مشترک؛ عینِ اندروید (ShopProfileScreen.tsx: identityRow) */}
|
||||
<div className="flex w-full items-start justify-between gap-2 py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex min-w-0 shrink-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
"flex min-w-0 max-w-full flex-col gap-1",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
"flex max-w-full items-center gap-1 truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
<VerificationBadge
|
||||
verifyBadge={resolveShopVerificationTier(shop.owner?.verify_badge)}
|
||||
size={16}
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
"min-w-0 max-w-full leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
@@ -253,14 +266,6 @@ export default function ShopProfileClient() {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -299,6 +304,16 @@ export default function ShopProfileClient() {
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requireAuth()) return;
|
||||
setShowSendModal(true);
|
||||
}}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.sendShopTitle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
@@ -318,7 +333,15 @@ export default function ShopProfileClient() {
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
// چیدمانِ این صفحه ambient dir معکوسیه (dir={isFa?"ltr":"rtl"} چند خط
|
||||
// بالاتر، برای بخشهای دیگهی همین صفحه). گریدِ عکسِ کالاها باید عینِ
|
||||
// اندروید (چیدمانِ عادی/غیرمعکوس، راستبهچپ برای فارسی) باشه، پس اینجا
|
||||
// با dir عادی override میشه. گردی هم قبلاً responsive (۶px زیرِ sm، ۱۶px
|
||||
// بالاش) بود، الان همیشه ۱۶px — عینِ tileImg توی ShopProfileScreen.tsx
|
||||
<div
|
||||
className="grid grid-cols-3 gap-0.5 sm:gap-1"
|
||||
dir={isFa ? "rtl" : "ltr"}
|
||||
>
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
@@ -330,7 +353,7 @@ export default function ShopProfileClient() {
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${tileId}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-2xl dark:bg-neutral-800"
|
||||
className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800"
|
||||
>
|
||||
{listing.isTaggedPost && (
|
||||
<span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||
@@ -344,7 +367,6 @@ export default function ShopProfileClient() {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -366,6 +388,13 @@ export default function ShopProfileClient() {
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<SendShopModal
|
||||
open={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
logo={shop.logo}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
|
||||
@@ -51,16 +51,25 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
(currentPage.startsWith("/projects") ||
|
||||
currentPage === "/new-project")) ||
|
||||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
|
||||
// isActive فقط برای رنگ/هایلایتِ آیکونه — شاملِ زیرصفحهها هم میشه (مثلاً
|
||||
// /settings/wallet). اگه کلیک روی خودِ همین isActive شرط بشه، وقتی از یه
|
||||
// زیرصفحهی تنظیمات روی آیکونِ تنظیمات بزنی، چون isActive از قبل true هست
|
||||
// preventDefault صدا زده میشه و Link اصلاً navigate نمیکنه — کاربر مجبور
|
||||
// میشد اول یه تبِ دیگه بزنه بعد دوباره تنظیمات، تا واقعاً برگرده به /settings.
|
||||
// اینترسپت (برای اسکرولبهبالا/رویدادِ تب) فقط وقتی درسته که دقیقاً همون
|
||||
// مسیرِ رَوتِ تب باشیم؛ وگرنه باید بذاریم Link عادی navigate کنه
|
||||
const isExactRoot = currentPage === tab.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
onClick={(e) => {
|
||||
// وقتی تبی که از قبل توش هستیم دوباره زده میشه، Next.js چون مسیر عوض
|
||||
// نمیشه هیچ اسکرول/رفرشی انجام نمیده — طبق درخواست کاربر، مثل اینستاگرام
|
||||
// باید صفحه بره بالا و (برای تبِ خانه) جدیدترین پستها هم لود بشن
|
||||
if (isActive) {
|
||||
// وقتی تبی که از قبل توش هستیم (دقیقاً همون صفحهی روتش) دوباره زده
|
||||
// میشه، Next.js چون مسیر عوض نمیشه هیچ اسکرول/رفرشی انجام نمیده —
|
||||
// طبق درخواست کاربر، مثل اینستاگرام باید صفحه بره بالا و (برای تبِ
|
||||
// خانه) جدیدترین پستها هم لود بشن
|
||||
if (isExactRoot) {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("app:tab-reselect", {
|
||||
|
||||
@@ -426,9 +426,14 @@ function MainModelCard({
|
||||
<DiscountBadge percent={Number(postData.offer)} className="mr-2" />
|
||||
) : null}
|
||||
</span>
|
||||
{type ? (
|
||||
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{type}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
<div className="flex items-center justify-between gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1 truncate text-foreground">
|
||||
{cuorse_name}
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</h2>
|
||||
|
||||
@@ -118,8 +118,15 @@ function BillboardsFilter() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-end gap-3 text-sm">
|
||||
<Link href="/billboards/new" aria-label={t("billboards.createAria")}>
|
||||
{/* h-9 w-9 flex ثابت روی هر ۴ دکمه — قبلاً هرکدوم بدون کادرِ هماندازه بودن (Link خام،
|
||||
button خام)، پس همترازیِ دقیقِ عمودیشون به رفتار پیشفرضِ مرورگر برای هر تگ
|
||||
وابسته بود؛ الان هر ۴ تا دقیقاً یه اندازه و یهراستا هستن */}
|
||||
<div className="flex items-center justify-end gap-1 text-sm">
|
||||
<Link
|
||||
href="/billboards/new"
|
||||
aria-label={t("billboards.createAria")}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
>
|
||||
<BoldIcon
|
||||
name="add-square"
|
||||
size={25}
|
||||
@@ -127,7 +134,11 @@ function BillboardsFilter() {
|
||||
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
|
||||
/>
|
||||
</Link>
|
||||
<button onClick={() => setShowSearchBox((v) => !v)} aria-label={t("billboards.search")}>
|
||||
<button
|
||||
onClick={() => setShowSearchBox((v) => !v)}
|
||||
aria-label={t("billboards.search")}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
@@ -136,7 +147,11 @@ function BillboardsFilter() {
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
<button onClick={() => setShowFilterModal(true)} aria-label={t("billboards.filter")}>
|
||||
<button
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
aria-label={t("billboards.filter")}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
@@ -145,6 +160,18 @@ function BillboardsFilter() {
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
<Link
|
||||
href="/settings/booking"
|
||||
aria-label={t("billboards.onlineBookingAria")}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
>
|
||||
<BoldIcon
|
||||
name="calendar"
|
||||
size={25}
|
||||
tinted
|
||||
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showSearchBox && (
|
||||
|
||||
@@ -138,7 +138,6 @@ function MainBillboardCard({
|
||||
height={120}
|
||||
alt={billboard?.title}
|
||||
src={`${IMAGE_BASE_URL}${billboard.images[0]}`}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-full bg-gray-300 flex items-center justify-center h-[86px] w-[86px] md:h-[120px] md:w-[120px] mt-4">
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import ProfileAvatar from "../main/ProfileAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ChatHeaderMenu from "./ChatHeaderMenu";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -21,8 +20,11 @@ export const chatHeaderCircleBtn = cn(
|
||||
"dark:text-neutral-100"
|
||||
);
|
||||
|
||||
// اندازهی آواتار از propِ sizePx روی ProfileAvatar میاد (کلاسِ CSS اینجا بیاثره
|
||||
// چون این کامپوننت با userId کار میکنه) — فعلاً 38px؛ pr پایین هم ۳px هست تا با
|
||||
// فاصلهی بالا/پایینِ خودکار (h-11=44px منهایِ ۳۸px آواتار، تقسیمبر۲) یکی بشه
|
||||
export const chatHeaderInfoPill = cn(
|
||||
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
|
||||
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[3px] pl-3",
|
||||
"active:scale-[0.98]"
|
||||
);
|
||||
|
||||
@@ -42,7 +44,6 @@ function ChatHeader({
|
||||
deleteMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const onlineLabel = t("chats.online");
|
||||
@@ -59,15 +60,17 @@ function ChatHeader({
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
{/* router.back() نامطمئن بود — اگه کاربر مستقیم (لینک/نوتیفیکیشن) وارد این
|
||||
صفحه شده باشه، تاریخچهی مرورگر برای برگشت وجود نداره و دکمه هیچ کاری
|
||||
نمیکرد. عینِ هدرِ گروه (rooms/[roomId]/page.tsx) یه مقصدِ ثابت داره */}
|
||||
<Link
|
||||
href="/settings/chats"
|
||||
className={chatHeaderCircleBtn}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={user?.blocked_you ? "#" : `/users/${user?.user_name}`}
|
||||
@@ -104,14 +107,13 @@ function ChatHeader({
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
alt={user.user_name || ""}
|
||||
size="xs"
|
||||
sizePx={38}
|
||||
rounded="full"
|
||||
className="h-[34px] w-[34px] shrink-0"
|
||||
userId={user._id}
|
||||
verifyBadge={user.verify_badge}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
<div className="h-[38px] w-[38px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
|
||||
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
|
||||
import SharedShopBubble, { parseSharedShop } from "./SharedShopBubble";
|
||||
import SharedGiftBubble, { parseSharedGift } from "./SharedGiftBubble";
|
||||
import SharedStoryBubble, {
|
||||
parseSharedStory,
|
||||
@@ -202,6 +203,65 @@ function PreviewOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/** عینِ PreviewOverlay: با دکمهی بکِ مرورگر/موبایل هم باید finishViewOnce صدا زده
|
||||
* بشه (که فایلِ یکبارمصرف رو سمتِ سرور کامل/حذف میکنه) — قبلاً این مودال فقط با
|
||||
* کلیکِ صریح بسته میشد، پس با بکِ سیستم، فایل سمتِ سرور هیچوقت finalize نمیشد */
|
||||
function ViewOnceOverlay({
|
||||
url,
|
||||
type,
|
||||
onClose,
|
||||
}: {
|
||||
url: string;
|
||||
type: "image" | "video";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
window.history.pushState({ chatMediaPreview: true }, "");
|
||||
const onPopState = () => onClose();
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [onClose]);
|
||||
|
||||
const requestClose = () => {
|
||||
if (window.history.state?.chatMediaPreview) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="max-h-[92vh] max-w-[95vw] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === "video" ? (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="max-h-[88vh] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={url} alt="" className="max-h-[88vh] object-contain" />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
|
||||
if (!content || isE2eeEnvelope(content)) return null;
|
||||
try {
|
||||
@@ -351,6 +411,7 @@ const ChatMessageCard = ({
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
const sharedPost = parseSharedPost(message.content || "");
|
||||
const sharedProduct = parseSharedProduct(message.content || "");
|
||||
const sharedShop = parseSharedShop(message.content || "");
|
||||
const sharedGift = parseSharedGift(message.content || "");
|
||||
const sharedStory = parseSharedStory(message.content || "");
|
||||
const storyReactionText = sharedStory
|
||||
@@ -754,6 +815,16 @@ const ChatMessageCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedShop && (
|
||||
<div
|
||||
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<SharedShopBubble data={sharedShop} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedGift && (
|
||||
<div
|
||||
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
|
||||
@@ -824,38 +895,11 @@ const ChatMessageCard = ({
|
||||
|
||||
<AnimatePresence>
|
||||
{viewOncePreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
onClick={() => void finishViewOnce()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="max-h-[92vh] max-w-[95vw] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{viewOncePreview.type === "video" ? (
|
||||
<video
|
||||
src={viewOncePreview.url}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="max-h-[88vh] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewOncePreview.url}
|
||||
alt=""
|
||||
className="max-h-[88vh] object-contain"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<ViewOnceOverlay
|
||||
url={viewOncePreview.url}
|
||||
type={viewOncePreview.type}
|
||||
onClose={() => void finishViewOnce()}
|
||||
/>
|
||||
)}
|
||||
{preview && (
|
||||
<PreviewOverlay
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
@@ -38,7 +37,6 @@ export default function GiftInChatModal({
|
||||
}: GiftInChatModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(PRESET_AMOUNTS[0]);
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
@@ -79,7 +77,9 @@ export default function GiftInChatModal({
|
||||
reset();
|
||||
onClose();
|
||||
} else if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = response.paymentUrl;
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PARTICLES = ["🎉", "🎊", "✨", "🎈", "🎁"];
|
||||
// طبقِ خواستهی کاربر: بعدِ باز شدنِ کادو، مبلغ بهصورتِ انیمیشنی «پرواز» میکنه و توی
|
||||
// آیکونِ کیفپول فرود میاد — همون زمانبندی نسخهی اندروید (GiftRevealModal.tsx: Reanimated)
|
||||
const COIN_FLY_DELAY_S = 0.75;
|
||||
const COIN_FLY_DURATION_S = 0.55;
|
||||
const WALLET_ARRIVAL_S = COIN_FLY_DELAY_S + COIN_FLY_DURATION_S;
|
||||
|
||||
type Particle = {
|
||||
id: number;
|
||||
@@ -89,14 +94,46 @@ export default function GiftRevealModal({
|
||||
🎁
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
|
||||
className="text-2xl font-extrabold text-white drop-shadow"
|
||||
<div className="relative flex items-center justify-center">
|
||||
<motion.p
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
|
||||
className="text-2xl font-extrabold text-white drop-shadow"
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</motion.p>
|
||||
<motion.span
|
||||
initial={{ opacity: 1, y: 6, scale: 1 }}
|
||||
animate={{ y: 96, scale: 0.45, opacity: 0 }}
|
||||
transition={{
|
||||
delay: COIN_FLY_DELAY_S,
|
||||
duration: COIN_FLY_DURATION_S,
|
||||
ease: "easeIn",
|
||||
}}
|
||||
className="pointer-events-none absolute top-1 text-2xl"
|
||||
>
|
||||
🪙
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.25, type: "spring", stiffness: 240, damping: 18 }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-white/10 px-3.5 py-1.5"
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</motion.p>
|
||||
<motion.span
|
||||
animate={{ scale: [1, 1, 1.25, 1] }}
|
||||
transition={{ delay: WALLET_ARRIVAL_S, duration: 0.35, times: [0, 0.4, 0.7, 1] }}
|
||||
className="text-lg"
|
||||
>
|
||||
👛
|
||||
</motion.span>
|
||||
<span className="text-xs font-medium text-white">
|
||||
{t("chats.gift.walletLabel")}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{message ? (
|
||||
<motion.p
|
||||
@@ -109,7 +146,14 @@ export default function GiftRevealModal({
|
||||
</motion.p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-white/70">{t("chats.gift.creditedHint")}</p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: WALLET_ARRIVAL_S, duration: 0.3 }}
|
||||
className="text-xs text-white/70"
|
||||
>
|
||||
{t("chats.gift.creditedHint")}
|
||||
</motion.p>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="240px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function SharedProductBubble({ data }: { data: SharedProductPaylo
|
||||
>
|
||||
{preview && (
|
||||
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">
|
||||
<Image src={preview} alt="" fill className="object-cover" unoptimized />
|
||||
<Image src={preview} alt="" fill sizes="240px" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1.5">
|
||||
|
||||
45
src/components/chat/SharedShopBubble.tsx
Normal file
45
src/components/chat/SharedShopBubble.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SharedShopPayload {
|
||||
shopId: string;
|
||||
name?: string;
|
||||
logo?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function parseSharedShop(content: string): SharedShopPayload | null {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
const j = JSON.parse(line);
|
||||
if (j?.e2ee) return null;
|
||||
return j.sharedShop ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SharedShopBubble({ data }: { data: SharedShopPayload }) {
|
||||
const { t } = useTranslation("common");
|
||||
const href = data.url || `/shops/profile/${data.shopId}`;
|
||||
const logo = data.logo ? IMAGE_BASE_URL + data.logo : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center gap-2 overflow-hidden rounded-2xl border border-white/20 bg-black/10 p-2 min-w-[200px] max-w-[240px]"
|
||||
>
|
||||
<span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-800">
|
||||
{logo && <Image src={logo} alt="" fill sizes="40px" className="object-cover" />}
|
||||
</span>
|
||||
<p className="truncate text-[11px] font-semibold opacity-90">
|
||||
{data.name || t("shops.viewShop")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +79,8 @@ export default function SharedStoryBubble({
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="120px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -326,12 +326,9 @@ export function CourseManager() {
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academyCourse.redirectedToPayment"));
|
||||
|
||||
setTimeout(() => {
|
||||
setIsDialogOpen1?.(false);
|
||||
}, 1000);
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست — window.location.href مطمئنتره
|
||||
window.location.href = paymentUrl;
|
||||
} else {
|
||||
console.error("ساختار پاسخ نامعتبر:", response);
|
||||
toast.error(
|
||||
|
||||
@@ -406,6 +406,20 @@ export default function ExploreReelsView({
|
||||
<ReelsPostCard
|
||||
postData={item.post}
|
||||
isActive={item.key === activeKey}
|
||||
preloadPriority={
|
||||
item.key === activeKey
|
||||
? "active"
|
||||
: Math.abs(index - activeIndex) <= 1
|
||||
? "nearby"
|
||||
: "idle"
|
||||
}
|
||||
onRequestNext={() => {
|
||||
if (!scrollRef.current || index + 1 >= items.length) return;
|
||||
scrollRef.current.scrollTo({
|
||||
top: (index + 1) * getReelsStepHeight(scrollRef.current),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ReelsAcademyCard
|
||||
|
||||
@@ -96,7 +96,6 @@ export default function ExploreVideoThumb({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
) : !frameReady ? (
|
||||
<div className="absolute inset-0 animate-pulse bg-neutral-800" />
|
||||
|
||||
@@ -321,8 +321,8 @@ export default function ReelsAcademyCard({
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,10 @@ type ProfileAvatarProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
size?: keyof typeof sizeMap;
|
||||
/** Exact pixel size, overriding the `size` preset — for spots that need a value
|
||||
* between presets (e.g. 38px). Works whether or not `userId`/StoryRing is used,
|
||||
* unlike `className` (see note below). */
|
||||
sizePx?: number;
|
||||
rounded?: "2xl" | "xl" | "full";
|
||||
className?: string;
|
||||
fallback?: string;
|
||||
@@ -42,6 +46,7 @@ function ProfileAvatar({
|
||||
src,
|
||||
alt = "",
|
||||
size = "md",
|
||||
sizePx,
|
||||
rounded = "2xl",
|
||||
className,
|
||||
fallback = "/images/fake-avatar.png",
|
||||
@@ -56,23 +61,25 @@ function ProfileAvatar({
|
||||
: "rounded-2xl";
|
||||
|
||||
const imageSrc = src ? buildStorageUrl(src) : fallback;
|
||||
const resolvedSizePx = sizePx ?? sizePxMap[size];
|
||||
|
||||
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
|
||||
|
||||
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
|
||||
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
|
||||
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
|
||||
// visible spacing (see the search-list "avatar stuck to text" regression).
|
||||
// visible spacing (see the search-list "avatar stuck to text" regression). `sizePx` isn't
|
||||
// affected by that — it always drives the actual pixel size via inline style below.
|
||||
const image = (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
width={280}
|
||||
height={280}
|
||||
unoptimized
|
||||
width={resolvedSizePx}
|
||||
height={resolvedSizePx}
|
||||
style={sizePx ? { width: sizePx, height: sizePx } : undefined}
|
||||
className={cn(
|
||||
"profile-avatar aspect-square object-cover shrink-0",
|
||||
sizeMap[size],
|
||||
!sizePx && sizeMap[size],
|
||||
roundedClass,
|
||||
!userId && className
|
||||
)}
|
||||
@@ -83,15 +90,15 @@ function ProfileAvatar({
|
||||
return image;
|
||||
}
|
||||
|
||||
// Keep the total rendered footprint equal to sizePxMap[size] in both states.
|
||||
// Keep the total rendered footprint equal to resolvedSizePx in both states.
|
||||
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
|
||||
// pre-shrink the photo by the ring's width; when it's false we pass the full size
|
||||
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
|
||||
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
|
||||
// avatar with a ring never shifts that row's height or the next element's position.
|
||||
const avatarSizeForRing = hasStory
|
||||
? Math.max(0, sizePxMap[size] - STORY_RING_PX * 2)
|
||||
: sizePxMap[size];
|
||||
? Math.max(0, resolvedSizePx - STORY_RING_PX * 2)
|
||||
: resolvedSizePx;
|
||||
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,6 @@ const ServiceItem: React.FC<ServiceItemProps> = ({ service, onDelete }) => {
|
||||
height={50}
|
||||
src={buildStorageUrl(image) || image}
|
||||
alt={title}
|
||||
unoptimized
|
||||
className="min-w-[50px] min-h-[50px] rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -34,6 +34,14 @@ export function resolveVerificationStatus(
|
||||
return verifyBadge as VerifyBadgeTier;
|
||||
}
|
||||
|
||||
/** طبق خواستهی صریح کاربر: تیکِ وریفایِ فروشگاه (برخلافِ کاربر که چهار سطح رنگی داره)
|
||||
* همیشه سبز باشه، مستقل از سطح واقعیِ verify_badge — فقط برای رندرِ فروشگاه استفاده بشه */
|
||||
export function resolveShopVerificationTier(
|
||||
verifyBadge?: string | null
|
||||
): "green" | null {
|
||||
return resolveVerificationStatus(verifyBadge) ? "green" : null;
|
||||
}
|
||||
|
||||
const VERIFICATION_ICON_SRC: Record<Exclude<VerifyBadgeTier, "none">, string> = {
|
||||
gray: "/images/icons/verify-gray.svg",
|
||||
blue: "/images/icons/verify-blue.svg",
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function AppearanceColorSelect({
|
||||
src={selected.src}
|
||||
alt={selected.label}
|
||||
fill
|
||||
sizes="36px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -93,8 +93,8 @@ export default function AppearanceColorSelect({
|
||||
src={opt.src}
|
||||
alt={opt.label}
|
||||
fill
|
||||
sizes="56px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-500">{opt.label}</span>
|
||||
|
||||
@@ -222,9 +222,9 @@ function MainModelCard({
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="72px"
|
||||
className="aspect-square rounded-2xl object-cover"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
|
||||
@@ -96,9 +96,13 @@ function MainModelCardActions({ postData }: { postData: Post }) {
|
||||
/>
|
||||
))}
|
||||
<div className="mt-2 flex w-full flex-row items-center justify-between px-6 sm:px-8">
|
||||
<SocialIconButton
|
||||
<SocialStatButton
|
||||
layout="horizontal"
|
||||
onClick={() => setShowServicesModal(true)}
|
||||
requestCount={social.offersCount}
|
||||
onClick={() => {
|
||||
social.trackOffer();
|
||||
setShowServicesModal(true);
|
||||
}}
|
||||
aria-label={t("models.actions.collaborationAria")}
|
||||
icon={
|
||||
<PostSocialIcon
|
||||
|
||||
@@ -120,7 +120,6 @@ const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
|
||||
loading={idx === 0 ? "eager" : "lazy"}
|
||||
className="min-h-[30vh] w-full rounded-2xl object-contain shadow-lg transition-opacity duration-300"
|
||||
onLoadingComplete={() => setImageLoading?.(false)}
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,6 @@ function ModelContentPosts({
|
||||
height={300}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
) : poster ? (
|
||||
<Image
|
||||
@@ -157,7 +156,6 @@ function ModelContentPosts({
|
||||
height={300}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
|
||||
@@ -111,8 +111,8 @@ export default function ProfileUserListModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -199,16 +199,14 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
>
|
||||
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
{/* طبق خواستهی صریح کاربر: فعلاً که تعداد فروشگاهها کمه این آیکون مخفیه؛
|
||||
هر وقت تعداد فروشگاهها زیاد شد از کامنت درش بیارید */}
|
||||
{/* <button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/shops/discover")}
|
||||
aria-label={t("filters.toolbar.shop")}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button> */}
|
||||
</button>
|
||||
</div>
|
||||
{showFilterModal && (
|
||||
<FilterModal
|
||||
|
||||
@@ -77,9 +77,10 @@ export default function ExpandableCaption({
|
||||
<>
|
||||
{body.trim() ? (
|
||||
<div
|
||||
onClick={isReels ? onToggle : undefined}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-6",
|
||||
isReels ? "text-white/95" : "text-justify",
|
||||
isReels ? "cursor-pointer text-white/95" : "text-justify",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -733,7 +733,9 @@ export default function PostFeedView({
|
||||
{showReelsTabs ? (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(2.25rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
|
||||
// ارتفاعِ pt طوری حساب شده که مرکزِ عمودیِ متنِ تبها با مرکزِ عمودیِ
|
||||
// دکمهی بک (h-9=36px, top: 1rem+safe-area) همتراز بشه
|
||||
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(1.5rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
|
||||
tabsHidden ? "-translate-y-full opacity-0" : "translate-y-0 opacity-100"
|
||||
)}
|
||||
>
|
||||
@@ -800,6 +802,13 @@ export default function PostFeedView({
|
||||
isActive={String(post._id) === String(activePostId)}
|
||||
postPage={feedMode === "user" && showClose}
|
||||
preloadPriority={preloadPriority}
|
||||
onRequestNext={() => {
|
||||
if (!scrollRef.current || index + 1 >= displayPosts.length) return;
|
||||
scrollRef.current.scrollTo({
|
||||
top: (index + 1) * getReelsStepHeight(scrollRef.current),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ReelsScrollSlot>
|
||||
);
|
||||
|
||||
@@ -63,8 +63,8 @@ function LinkListItem({
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<BoldIcon name={fallbackIcon} size={20} className="dark:invert" />
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function ReelsImageCarousel({
|
||||
src={mediaFiles[0].src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain select-none"
|
||||
priority={priority}
|
||||
draggable={false}
|
||||
onLoad={() => onLoadingComplete?.()}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -182,6 +182,7 @@ export default function ReelsImageCarousel({
|
||||
src={file.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain select-none"
|
||||
priority={priority && Math.abs(idx - index) <= 1}
|
||||
loading={
|
||||
@@ -191,7 +192,6 @@ export default function ReelsImageCarousel({
|
||||
onLoad={() => {
|
||||
if (idx === index) onLoadingComplete?.();
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,7 @@ import ModelServiceModal from "@/components/models/ModelServiceModal";
|
||||
import { useUserById } from "@/hooks/getUserById";
|
||||
import SharePostModal from "@/components/main/SharePostModal";
|
||||
import SendPostModal from "@/components/posts/SendPostModal";
|
||||
import { buildStorageUrl, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { SocialStatButton, SocialIconButton } from "@/components/ui/SocialStatButton";
|
||||
import {
|
||||
POST_SOCIAL_ICONS,
|
||||
@@ -59,15 +59,18 @@ function ReelsProfileFollowButton({
|
||||
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
|
||||
alt={userName}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* mb-[7px]: چون بجِ فالو با -bottom-[7px] از جعبهی آواتار بیرون میزنه، فاصلهی
|
||||
واقعیش تا آیکونِ بعدی (فنجون) از gap-4 ستون کمتر میشد؛ این مارجین همون ۷px
|
||||
رو جبران میکنه تا فاصله با همهی آیکونهای دیگه یکی بشه */}
|
||||
if (isOwnPost) {
|
||||
return (
|
||||
<Link href={`/users/${userName}`} className="mb-0 block">
|
||||
<Link href={`/users/${userName}`} className="mb-[7px] block">
|
||||
{avatar}
|
||||
</Link>
|
||||
);
|
||||
@@ -77,11 +80,14 @@ function ReelsProfileFollowButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFollow}
|
||||
className="relative mb-0 block"
|
||||
className="relative mb-[7px] block"
|
||||
aria-label={following ? t("posts.unfollow") : t("posts.follow")}
|
||||
>
|
||||
{avatar}
|
||||
<span className="absolute -bottom-1 left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
|
||||
{/* -bottom-1(-4px) بود؛ طبقِ خواستهی کاربر ۳px پایینتر رفت (مثلِ اندروید) —
|
||||
چون بزرگترشدنِ مقدارِ منفیِ bottom روی absolute یعنی پایینترِ لبهی
|
||||
پرنت، عددِ درست -7px بود نه -1px (اشتباهِ قبلی: جهتِ عکس رفته بود) */}
|
||||
<span className="absolute -bottom-[7px] left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
|
||||
{following ? (
|
||||
<BoldIcon
|
||||
name="tick-circle"
|
||||
@@ -118,13 +124,15 @@ function ReelsStoryShareButton({
|
||||
className="inline-flex shrink-0 items-center justify-center disabled:opacity-60"
|
||||
aria-label={t("posts.addToStory")}
|
||||
>
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/story.png")}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 invert brightness-125 drop-shadow-lg"
|
||||
unoptimized
|
||||
{/* قبلاً story.png (رستر) با فیلترِ invert/brightness رنگ میگرفت که رنگ/ضخامتش
|
||||
با بقیهی آیکونهای ستون (که ماسکِ bg-current رو رویِ SVG وکتور میزنن) یکی
|
||||
نمیشد؛ همون آیکونِ story.svg از ست vuesax/bold با تکنیکِ ماسکِ BoldIcon
|
||||
استفاده میشه تا دقیقاً همرنگ و همضخامتِ بقیه بشه */}
|
||||
<BoldIcon
|
||||
name="story"
|
||||
size={28}
|
||||
tinted
|
||||
className="text-white drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -208,9 +216,13 @@ export default function ReelsPostActions({
|
||||
|
||||
const postPageActions = (
|
||||
<>
|
||||
<SocialIconButton
|
||||
<SocialStatButton
|
||||
layout="horizontal"
|
||||
onClick={() => setShowServicesModal(true)}
|
||||
requestCount={social.offersCount}
|
||||
onClick={() => {
|
||||
social.trackOffer();
|
||||
setShowServicesModal(true);
|
||||
}}
|
||||
className={actionButtonClass}
|
||||
aria-label={t("posts.collaborationRequest")}
|
||||
icon={<PostSocialIcon {...POST_SOCIAL_ICONS.collaboration(postIconSize)} />}
|
||||
@@ -286,6 +298,12 @@ export default function ReelsPostActions({
|
||||
|
||||
const reelsActions = (
|
||||
<>
|
||||
{onShareToStory ? (
|
||||
<ReelsStoryShareButton
|
||||
disabled={sharingStory}
|
||||
onClick={onShareToStory}
|
||||
/>
|
||||
) : null}
|
||||
<ReelsProfileFollowButton
|
||||
userName={user_name}
|
||||
profileImage={user?.profile_image}
|
||||
@@ -293,9 +311,13 @@ export default function ReelsPostActions({
|
||||
isOwnPost={social.isOwnPost}
|
||||
onToggleFollow={social.toggleFollow}
|
||||
/>
|
||||
<SocialIconButton
|
||||
<SocialStatButton
|
||||
layout="vertical"
|
||||
onClick={() => setShowServicesModal(true)}
|
||||
requestCount={social.offersCount}
|
||||
onClick={() => {
|
||||
social.trackOffer();
|
||||
setShowServicesModal(true);
|
||||
}}
|
||||
className={reelsActionButtonClass}
|
||||
aria-label={t("posts.collaborationRequest")}
|
||||
icon={
|
||||
@@ -306,7 +328,7 @@ export default function ReelsPostActions({
|
||||
layout="vertical"
|
||||
likeCount={social.likesCountState}
|
||||
onClick={social.toggleLike}
|
||||
className={reelsActionButtonClass}
|
||||
className={cn(reelsActionButtonClass, "-mt-[3px]")}
|
||||
aria-label={t("posts.like")}
|
||||
icon={
|
||||
<PostSocialIcon
|
||||
@@ -407,18 +429,13 @@ export default function ReelsPostActions({
|
||||
/* عرضِ ستون ثابت (w-12، هماندازهی آواتار که پهنترین آیتمشه) — وگرنه توی
|
||||
حالتِ جمعشده که آواتار نیست، ستون بهاندازهی آیکونِ باریکتر جمع میشد و
|
||||
دکمهی جمعشونده (که items-center وسطچینش میکنه) چند پیکسل به چپ میرفت */
|
||||
<div className="absolute bottom-36 left-3 z-30 flex w-12 flex-col items-center gap-4">
|
||||
{onShareToStory ? (
|
||||
<ReelsStoryShareButton
|
||||
disabled={sharingStory}
|
||||
onClick={onShareToStory}
|
||||
/>
|
||||
) : null}
|
||||
{/* دکمهی جمعشونده و منوی سهنقطه همیشه توی همین جای ثابت رندر میشن (مثل
|
||||
اندروید) — قبلاً با AnimatePresence mode="wait" بین دو درختِ جدا سوییچ
|
||||
میشد (جمعشده: [دکمه، منو] / بازشده: [آیکونها، دکمه، منو]) که باعث میشد
|
||||
جای دکمه و منو موقع باز/بستهکردن جابهجا بشه؛ الان فقط آیکونهای اکسترا
|
||||
بالای این دو تا اضافه/کم میشن، بدون جابهجایی */}
|
||||
<div className="absolute bottom-[72px] left-3 z-30 flex w-12 flex-col items-center gap-4">
|
||||
{/* دکمهی جمعشونده همیشه باید آخرین فرزندِ این ستون باشه (نه هرچیزِ دیگهای زیرش)
|
||||
— این ستون absolute + bottom-36 هست، پس لبهی پایینِ خودِ ستون همیشه ثابته،
|
||||
اما وقتی فرزندی که زیرِ دکمهی toggle قرار داره (مثلاً منوی سهنقطه) شرطی
|
||||
حذف/اضافه بشه، توتالهایتِ ستون تغییر میکنه و دکمهی toggle (که دیگه آخرین
|
||||
فرزند نیست) جابهجا میشه. با آوردنِ منو *قبل* از toggle، دکمهی toggle
|
||||
همیشه آخرین فرزند و جاش کاملاً ثابت میمونه، مطابقِ اندروید */}
|
||||
<AnimatePresence>
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
@@ -433,21 +450,39 @@ export default function ReelsPostActions({
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{/* طبق خواستهی کاربر: با جمعکردن آیکونها فقط خودِ دکمهی جمعکردن باقی بمونه،
|
||||
پس منوی سهنقطه هم باید مثل بقیهی اکشنها مخفی بشه — ولی چون *بالای* toggle
|
||||
میآد (نه زیرش)، حذف/اضافهشدنش دیگه جای toggle رو عوض نمیکنه */}
|
||||
{expanded ? optionsMenu : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-center"
|
||||
aria-label={expanded ? t("posts.close") : t("posts.showActions")}
|
||||
>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt=""
|
||||
src="/images/icons/actions-toggle.svg"
|
||||
className={cn("drop-shadow-lg invert", expanded && "opacity-80")}
|
||||
{/* قبلاً با Image + فیلترِ invert/brightness رنگ میگرفت — چون fill خودِ
|
||||
SVG (#292D32) دقیقاً سیاهِ خالص نیست، بسته به مرورگر گاهی بهجای
|
||||
سفیدِ خالص یه سایهی نزدیکبهسفید درمیاومد. با ماسکِ bg-current
|
||||
(عینِ بقیهی آیکونهای این ستون) دیگه به fill اصلیِ svg وابسته
|
||||
نیست و همیشه دقیقاً همرنگِ بقیه سفیدِ خالصه */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block h-7 w-7 shrink-0 bg-white drop-shadow-lg",
|
||||
expanded && "opacity-80"
|
||||
)}
|
||||
style={{
|
||||
WebkitMaskImage: "url(/images/icons/actions-toggle.svg)",
|
||||
maskImage: "url(/images/icons/actions-toggle.svg)",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
maskPosition: "center",
|
||||
WebkitMaskSize: "contain",
|
||||
maskSize: "contain",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{optionsMenu}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -130,8 +130,8 @@ export default function ReelsPostAnalyticsModal({
|
||||
src={previewSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="112px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -68,6 +68,8 @@ interface ReelsPostCardProps {
|
||||
isActive?: boolean;
|
||||
postPage?: boolean;
|
||||
preloadPriority?: VideoPreloadPriority;
|
||||
/** وقتی پیمایش خودکار فعاله و این ریلز (فعال) به انتهای ویدیو رسید */
|
||||
onRequestNext?: () => void;
|
||||
}
|
||||
|
||||
export default function ReelsPostCard({
|
||||
@@ -75,6 +77,7 @@ export default function ReelsPostCard({
|
||||
isActive = false,
|
||||
postPage = false,
|
||||
preloadPriority = "idle",
|
||||
onRequestNext,
|
||||
}: ReelsPostCardProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const {
|
||||
@@ -117,6 +120,7 @@ export default function ReelsPostCard({
|
||||
useEffect(() => {
|
||||
setCaptionExpanded(false);
|
||||
}, [postData._id]);
|
||||
|
||||
const [imageLoading, setImageLoading] = useState(() => (files?.length ?? 0) > 0);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
// وقتی postPage=true، کامنتها توی CommentsModal جدا (نه شیت این کامپوننت) باز میشه
|
||||
@@ -146,8 +150,21 @@ export default function ReelsPostCard({
|
||||
const social = usePostSocialState(postData);
|
||||
const requireAuth = useRequireAuth();
|
||||
const chromeHidden = exploreUi ? exploreUi.chromeHidden : uiHidden;
|
||||
// اطلاعات کاربر/کپشن هم باید با دکمهی جمعکردن آیکونها مخفی بشن، نه فقط با
|
||||
// hold روی تصویر (chromeHidden) — قبلاً این بلوک فقط chromeHidden رو چک میکرد
|
||||
const actionsExpanded = exploreUi ? exploreUi.actionsExpanded : true;
|
||||
const autoScrollEnabled = exploreUi ? exploreUi.autoScrollEnabled : false;
|
||||
const playbackSpeed = exploreUi ? exploreUi.playbackSpeed : 1;
|
||||
const allowSave = postData.author_allow_save !== false;
|
||||
|
||||
// عکس onEnded نداره (فقط ویدیو) — با پیمایش خودکار فعال، بعد از ۵ ثانیه خودش میره
|
||||
// ریلز بعدی؛ اگه این پست ویدیوعه این تایمر کاری نمیکنه (onEnded خودِ ویدیو مسئوله)
|
||||
useEffect(() => {
|
||||
if (!isActive || !autoScrollEnabled || type === "video") return;
|
||||
const timer = window.setTimeout(() => onRequestNext?.(), 5000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isActive, autoScrollEnabled, type, onRequestNext]);
|
||||
|
||||
const useCommentsSheet = !postPage;
|
||||
const anyCommentsOpen = commentsOpen || commentsModalOpen;
|
||||
const showLongVideoBar =
|
||||
@@ -155,7 +172,8 @@ export default function ReelsPostCard({
|
||||
videoDuration > LONG_VIDEO_SEC &&
|
||||
isActive &&
|
||||
!chromeHidden &&
|
||||
!anyCommentsOpen;
|
||||
!anyCommentsOpen &&
|
||||
actionsExpanded;
|
||||
|
||||
const mediaFiles =
|
||||
files?.map((file) => ({
|
||||
@@ -315,6 +333,12 @@ export default function ReelsPostCard({
|
||||
setVideoPaused(false);
|
||||
}, [videoSrc]);
|
||||
|
||||
// <video> عنصرِ HTML سرعتِ پخش رو بهعنوان JSX prop نمیگیره، باید مستقیم روی خودِ
|
||||
// المنت ست بشه — هم موقع تغییرِ سرعت از منو، هم موقع لود شدنِ منبعِ جدید
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.playbackRate = playbackSpeed;
|
||||
}, [playbackSpeed, videoSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "video" || !videoSrc) return;
|
||||
if (preloadPriority === "active" || preloadPriority === "nearby") {
|
||||
@@ -335,6 +359,13 @@ export default function ReelsPostCard({
|
||||
}
|
||||
}, [isActive, muted, type, commentsOpen, videoPaused]);
|
||||
|
||||
// videoPaused هم برای «کاربر دستی پاز کرده» و هم برای پازِ خودکارِ همین افکت (وقتی
|
||||
// isActive میشه false) استفاده میشه؛ بدون این ریست، برگشتن به ریلزی که قبلاً دیده
|
||||
// شده autoplay نمیکرد چون videoPaused از آخرین بار true مونده بود
|
||||
useEffect(() => {
|
||||
if (!isActive) setVideoPaused(false);
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setCommentsOpen(false);
|
||||
@@ -437,23 +468,37 @@ export default function ReelsPostCard({
|
||||
src={videoPoster}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
sizes="100vw"
|
||||
// object-bottom: طبق خواستهی کاربر، لبهی پایینِ محتوای ویدیو باید همیشه
|
||||
// دقیقاً روی لبهی پایینِ کادر بشینه (نه وسطچین) — تا صرفنظر از نسبتِ
|
||||
// ابعادِ صفحهنمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
|
||||
// لنگرگاه نمایش داده بشه، وگرنه لحظهی لود شدنِ ویدیو یه پرش دیده میشه
|
||||
className="object-contain object-bottom"
|
||||
priority={isActive || preloadPriority === "nearby"}
|
||||
draggable={!allowSave ? false : undefined}
|
||||
onContextMenu={
|
||||
!allowSave ? (e) => e.preventDefault() : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc ?? mediaFiles[0]?.src}
|
||||
poster={videoPoster}
|
||||
loop
|
||||
loop={!autoScrollEnabled}
|
||||
playsInline
|
||||
preload={videoPreload}
|
||||
controlsList="nodownload"
|
||||
className={cn(
|
||||
"h-full w-full object-contain",
|
||||
"h-full w-full object-contain object-bottom",
|
||||
videoPoster && !videoReady && "opacity-0"
|
||||
)}
|
||||
onClick={handleVideoClick}
|
||||
// مرورگر نمیتونه از اسکرینشات واقعی جلوگیری کنه (هیچ API مرورگری برای این
|
||||
// وجود نداره)؛ این فقط راههای سادهی دانلود/ذخیره (کلیک راست، درگ) رو میبندد
|
||||
onContextMenu={
|
||||
!allowSave ? (e) => e.preventDefault() : undefined
|
||||
}
|
||||
onLoadedData={() => {
|
||||
setImageLoading(false);
|
||||
setVideoReady(true);
|
||||
@@ -469,6 +514,9 @@ export default function ReelsPostCard({
|
||||
onProgress={handleVideoTimeUpdate}
|
||||
onPlay={() => setVideoPaused(false)}
|
||||
onPause={() => setVideoPaused(true)}
|
||||
onEnded={() => {
|
||||
if (isActive && autoScrollEnabled) onRequestNext?.();
|
||||
}}
|
||||
muted={muted}
|
||||
/>
|
||||
{videoPaused && isActive && !commentsOpen && (
|
||||
@@ -578,14 +626,20 @@ export default function ReelsPostCard({
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{!chromeHidden && !anyCommentsOpen && (
|
||||
{!chromeHidden && !anyCommentsOpen && actionsExpanded && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pt-16",
|
||||
// pl (نه px یکسان): زیرِ dir=rtl سمتِ فیزیکیِ چپ همون سمتیه که ستونِ
|
||||
// آیکونها (left-3 در ReelsPostActions) روشه؛ چون آخرین آیتمِ ردیفِ
|
||||
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت میشینه، این
|
||||
// فاصلهی اضافه (۶۴px، هماندازهی عرضِ ستونِ آیکونها) از افتادنِ
|
||||
// ریت/امتیاز زیرِ دکمهی جمعشونده جلوگیری میکنه
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
|
||||
// وقتی نوار پیشرفت ویدیو نمایش داده میشه، این بلوک باید بالای خطِ اون
|
||||
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصلهی پایینِ معمولی کافیه
|
||||
showLongVideoBar
|
||||
@@ -611,8 +665,8 @@ export default function ReelsPostCard({
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
|
||||
@@ -13,6 +13,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useExploreReelsUi,
|
||||
type PlaybackSpeed,
|
||||
} from "@/contexts/ExploreReelsUiContext";
|
||||
|
||||
const REPORT_REASON_IDS = [
|
||||
"inappropriate",
|
||||
@@ -22,6 +27,8 @@ const REPORT_REASON_IDS = [
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
const SPEED_OPTIONS: PlaybackSpeed[] = [0.5, 1, 1.5, 2];
|
||||
|
||||
type Props = {
|
||||
postData: Post;
|
||||
isOwnPost: boolean;
|
||||
@@ -42,6 +49,7 @@ export default function ReelsPostOptionsMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const canEdit = isOwnPost;
|
||||
const exploreUi = useExploreReelsUi();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false);
|
||||
@@ -193,6 +201,55 @@ export default function ReelsPostOptionsMenu({
|
||||
count: viewCount.toLocaleString(numberLocale),
|
||||
})}
|
||||
</button>
|
||||
{exploreUi ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-5 py-3.5 text-sm">
|
||||
<span>{t("posts.autoScrollLabel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={exploreUi.autoScrollEnabled}
|
||||
onClick={() =>
|
||||
exploreUi.setAutoScrollEnabled(!exploreUi.autoScrollEnabled)
|
||||
}
|
||||
className={cn(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
|
||||
exploreUi.autoScrollEnabled ? "bg-[#FC8EAC]" : "bg-neutral-300 dark:bg-neutral-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform",
|
||||
exploreUi.autoScrollEnabled ? "translate-x-0.5" : "translate-x-[22px]"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-5 py-3.5 text-sm">
|
||||
<span>{t("posts.playbackSpeedLabel")}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const active = exploreUi.playbackSpeed === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => exploreUi.setPlaybackSpeed(option)}
|
||||
className={cn(
|
||||
"flex h-8 min-w-[40px] items-center justify-center rounded-2xl border px-2 text-xs font-semibold",
|
||||
active
|
||||
? "border-[#FC8EAC] bg-[rgba(252,142,172,0.1)] text-[#FC8EAC]"
|
||||
: "border-[#C3C3C3] text-foreground"
|
||||
)}
|
||||
>
|
||||
{option}x
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{isOwnPost ? (
|
||||
<>
|
||||
{canEdit ? (
|
||||
|
||||
@@ -137,8 +137,8 @@ export default function SendPostModal({
|
||||
src={preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -181,8 +181,8 @@ export default function SendPostModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -114,8 +114,8 @@ export default function TagUsersPicker({
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="28px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -178,8 +178,8 @@ export default function TagUsersPicker({
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,6 @@ function SpecialistCard({
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="(min-width: 1024px) 220px, (min-width: 640px) 30vw, 78vw"
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -126,7 +125,6 @@ function SpecialistCard({
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -82,7 +82,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "280px" : "120px"}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -129,7 +128,6 @@ function SpecialistCard({
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
|
||||
@@ -8,7 +8,6 @@ import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IProjectRequest } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
@@ -24,7 +23,6 @@ function ProjectRequestsAction({
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [itemToAccept, setItemToAccept] = useState<IProjectRequest>();
|
||||
const [viewerUrl, setViewerUrl] = useState<string | null>(null);
|
||||
@@ -47,10 +45,12 @@ function ProjectRequestsAction({
|
||||
projectId,
|
||||
}
|
||||
);
|
||||
// لینک خارجی — router.push (next/navigation) رفتارِ تضمینشدهای برای URLهای
|
||||
// بیرونی نداره؛ window.location.href مطمئنتره (خصوصاً سافاری)
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
window.location.href = response.paymentUrl;
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -39,7 +39,9 @@ function ProjectWorkroomActions({
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -73,10 +73,13 @@ function ProjectWorkroomRate({
|
||||
"/projects/initiate-completion-payment/web",
|
||||
{ projectId }
|
||||
);
|
||||
// لینک خارجی — router.push (next/navigation) رفتارِ تضمینشدهای برای URLهای
|
||||
// بیرونی نداره؛ window.location.href مطمئنتره (خصوصاً سافاری). sessionStorage
|
||||
// بالا هنوز کار میکنه چون همین تب navigate میشه، نه یه تبِ جدید
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
window.location.href = response.paymentUrl;
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
|
||||
@@ -149,7 +149,6 @@ export default function ActivityPostGrid({ endpoint }: Props) {
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -58,8 +58,8 @@ export default function NotificationRow({
|
||||
src={avatarSrc}
|
||||
alt={actor?.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
||||
@@ -12,8 +12,12 @@ type PageTitleProps = {
|
||||
* Optional right-side controls (e.g. a page-specific action icon). Pass content
|
||||
* here instead of wrapping <PageTitle> in an outer flex row — an outer wrapper
|
||||
* shrinks this component to its own content width, which pushes the back button
|
||||
* away from the true screen edge. The back button's column is always a fixed
|
||||
* 2.5rem regardless of what's in this slot, so its position never moves.
|
||||
* away from the true screen edge. Both side columns are a fixed 2.5rem so the
|
||||
* title's center point never shifts — previously the right column was
|
||||
* minmax(2.5rem,auto), which grew with wider action content (e.g. a text
|
||||
* button) and dragged the visual center of the title off the true screen
|
||||
* center. Content wider than 2.5rem still renders at its natural size and
|
||||
* simply overflows its column instead of resizing it.
|
||||
*/
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
@@ -28,7 +32,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
|
||||
usePageTitle(titleText);
|
||||
|
||||
return (
|
||||
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_minmax(2.5rem,auto)] items-center gap-2">
|
||||
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_2.5rem] items-center gap-2">
|
||||
{showBack ? (
|
||||
<BackButton href={backHref} className="justify-self-start" />
|
||||
) : (
|
||||
|
||||
@@ -30,13 +30,13 @@ export default function SettingsShell({
|
||||
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50"
|
||||
>
|
||||
<TabNavigation
|
||||
currentPage={
|
||||
pathname?.startsWith("/settings/chats")
|
||||
? "/settings/chats"
|
||||
: "/settings"
|
||||
}
|
||||
/>
|
||||
{/* pathname واقعی رو پاس میدیم، نه همیشه "/settings" ثابت — قبلاً برای هر
|
||||
زیرصفحهی تنظیمات (مثلاً /settings/wallet) هم "/settings" پاس داده میشد،
|
||||
پس isExactRoot توی TabNavigation.tsx همیشه true میشد و کلیکِ روی آیکونِ
|
||||
تنظیمات preventDefault میشد (بهجای navigate واقعی)، چون فکر میکرد از
|
||||
قبل دقیقاً روی روتِ /settings هستیم. isActive/هایلایتِ تب همچنان درست
|
||||
کار میکنه چون TabNavigation خودش startsWith("/settings") رو چک میکنه */}
|
||||
<TabNavigation currentPage={pathname ?? "/settings"} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function VisitorRow({ visitor }: Props) {
|
||||
src={avatarSrc}
|
||||
alt={visitor.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ function MyBillboardStatusActions({
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
@@ -65,7 +67,9 @@ function MyBillboardStatusActions({
|
||||
} else {
|
||||
const authority = response.authority;
|
||||
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
|
||||
router.push(paymentUrl);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -54,7 +54,9 @@ export default function ProductCategoryPicker({
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(category === item.category),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
// گردی toggleBtnClass پیشفرض rounded-3xl(24px) هست؛ اینجا عینِ pill
|
||||
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override میشه
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
|
||||
)}
|
||||
onClick={() => onCategorySelect(item.category)}
|
||||
>
|
||||
@@ -76,7 +78,9 @@ export default function ProductCategoryPicker({
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(subCategory === item.name),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
// گردی toggleBtnClass پیشفرض rounded-3xl(24px) هست؛ اینجا عینِ pill
|
||||
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override میشه
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
|
||||
)}
|
||||
onClick={() => onSubCategorySelect(item.name)}
|
||||
>
|
||||
@@ -100,7 +104,9 @@ export default function ProductCategoryPicker({
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(subSubCategory === item.name),
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight"
|
||||
// گردی toggleBtnClass پیشفرض rounded-3xl(24px) هست؛ اینجا عینِ pill
|
||||
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override میشه
|
||||
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
|
||||
)}
|
||||
onClick={() => onSubSubCategorySelect(item.name)}
|
||||
>
|
||||
|
||||
@@ -71,7 +71,6 @@ export default function ProductImageLightbox({
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="100vw"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -125,8 +125,8 @@ export default function SendProductModal({
|
||||
src={IMAGE_BASE_URL + preview}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,8 +169,8 @@ export default function SendProductModal({
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
205
src/components/shops/SendShopModal.tsx
Normal file
205
src/components/shops/SendShopModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { btnPrimary } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ChatUserItem } from "./SendProductModal";
|
||||
|
||||
interface SendShopModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
logo?: string | null;
|
||||
}
|
||||
|
||||
// عینِ SendProductModal.tsx، فقط برای خودِ فروشگاه (نه یه کالای خاص) — بکاند
|
||||
// shopController.js: sendShopToUsers
|
||||
export default function SendShopModal({
|
||||
open,
|
||||
onClose,
|
||||
shopId,
|
||||
shopName,
|
||||
logo,
|
||||
}: SendShopModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [users, setUsers] = useState<ChatUserItem[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
|
||||
"GET",
|
||||
"/messages?limit=100",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers((res?.filteredUsersData || []).filter((u) => Boolean(u.user_name)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUsers();
|
||||
setSelected(new Set());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 50) next.add(id);
|
||||
else toast.error(t("posts.maxUsers"));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await request<{ sentCount?: number }>(
|
||||
"POST",
|
||||
`/shops/${shopId}/send`,
|
||||
{ receiverIds: Array.from(selected) },
|
||||
{ noToast: true }
|
||||
);
|
||||
toast.success(
|
||||
t("shops.sentShopToCount", { count: res?.sentCount ?? selected.size })
|
||||
);
|
||||
onClose();
|
||||
setSelected(new Set());
|
||||
} catch {
|
||||
toast.error(t("shops.sendShopError"));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
className="glass-modal-panel w-full max-w-md rounded-t-3xl p-4 sm:rounded-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-3 text-center text-sm font-bold">
|
||||
{t("shops.sendShopTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800">
|
||||
{logo && (
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + logo}
|
||||
alt=""
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="line-clamp-2 text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{shopName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("posts.noChatUsers")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-[45vh] space-y-1 overflow-y-auto">
|
||||
{users.map((u) => {
|
||||
const name =
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
|
||||
u.user_name;
|
||||
const checked = selected.has(u._id);
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u._id)}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition ${
|
||||
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={
|
||||
u.profile_image.startsWith("http")
|
||||
? u.profile_image
|
||||
: IMAGE_BASE_URL + u.profile_image
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full border-2",
|
||||
checked ? "btn-modern--selected border-[#FC8EAC]" : "border-neutral-300"
|
||||
)}
|
||||
>
|
||||
{checked ? (
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.size === 0 || sending}
|
||||
onClick={send}
|
||||
className={cn(btnPrimary, "mt-4 w-full py-3 text-sm font-bold disabled:opacity-40")}
|
||||
>
|
||||
{sending
|
||||
? t("posts.sending")
|
||||
: t("posts.sendWithCount", { count: selected.size })}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default function ShopCartTab() {
|
||||
{discountPercent != null && (
|
||||
<DiscountBadge
|
||||
percent={discountPercent}
|
||||
className="absolute right-1 top-1"
|
||||
className="absolute left-1 top-1"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -26,22 +26,32 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
|
||||
contactInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
// عین ShopProfileScreen.tsx اندروید: آیکونِ برندِ رنگی (نه آیکونِ عمومیِ تکرنگِ
|
||||
// قبلی که با dark:invert معکوس میشد)
|
||||
const contactMethods = [
|
||||
{ key: "mobile", icon: "mobile.svg", link: `tel:${contactInfo?.mobile}` },
|
||||
{ key: "landline", icon: "phone.svg", link: `tel:${contactInfo?.landline}` },
|
||||
{
|
||||
key: "mobile",
|
||||
icon: "brand/phone-brand.svg",
|
||||
link: `tel:${contactInfo?.mobile}`,
|
||||
},
|
||||
{
|
||||
key: "landline",
|
||||
icon: "brand/phone-brand.svg",
|
||||
link: `tel:${contactInfo?.landline}`,
|
||||
},
|
||||
{
|
||||
key: "whatsapp",
|
||||
icon: "whatsapp.svg",
|
||||
icon: "brand/whatsapp-brand.svg",
|
||||
link: `https://wa.me/${contactInfo?.whatsapp}`,
|
||||
},
|
||||
{
|
||||
key: "telegram",
|
||||
icon: "telegram.svg",
|
||||
icon: "brand/telegram-brand.svg",
|
||||
link: `https://t.me/${contactInfo?.telegram}`,
|
||||
},
|
||||
{
|
||||
key: "instagram",
|
||||
icon: "instagram.svg",
|
||||
icon: "brand/instagram-brand.svg",
|
||||
link: `https://instagram.com/${contactInfo?.instagram}`,
|
||||
},
|
||||
];
|
||||
@@ -64,11 +74,11 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
|
||||
<RoundedDiv className="h-9 w-full gap-2">
|
||||
<span>{contactInfo[key as keyof IShopContactInfo]}</span>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
width={22}
|
||||
height={22}
|
||||
alt={icon}
|
||||
src={`/images/icons/${icon}`}
|
||||
className="dark:invert"
|
||||
unoptimized
|
||||
/>
|
||||
</RoundedDiv>
|
||||
</a>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ShopFavoritesTab() {
|
||||
{discountPercent != null && (
|
||||
<DiscountBadge
|
||||
percent={discountPercent}
|
||||
className="absolute right-2 top-2"
|
||||
className="absolute left-2 top-2"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge, {
|
||||
resolveShopVerificationTier,
|
||||
} from "@/components/main/VerificationBadge";
|
||||
|
||||
type ShopProductAuthorProps = {
|
||||
shopName: string;
|
||||
shopLogo?: string | null;
|
||||
shopVerifyBadge?: string | null;
|
||||
};
|
||||
|
||||
export default function ShopProductAuthor({
|
||||
shopName,
|
||||
shopLogo,
|
||||
shopVerifyBadge,
|
||||
}: ShopProductAuthorProps) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
@@ -23,8 +28,12 @@ export default function ShopProductAuthor({
|
||||
) : (
|
||||
<span className="h-8 w-8 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<span className="flex min-w-0 items-center gap-1 truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{shopName}
|
||||
<VerificationBadge
|
||||
verifyBadge={resolveShopVerificationTier(shopVerifyBadge)}
|
||||
size={12}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -114,8 +114,8 @@ export default function ShopProductCommentsModal({
|
||||
src={IMAGE_BASE_URL + c.creator.profile_image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="36px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user