Connect storefront to backend catalog, search, and home slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 2f857f1a90
commit 0074ab6629
106 changed files with 5920 additions and 81 deletions
+3 -2
View File
@@ -1,2 +1,3 @@
NEXT_PUBLIC_STORE_URL=http://store.mana-collection.com:3001
NEXT_PUBLIC_API_URL=http://api.mana-collection.com:3002/api/v1
NEXT_PUBLIC_STORE_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3002/api/v1
NEXT_PUBLIC_STORE_SLUG=mana-collection
+6
View File
@@ -12,6 +12,12 @@ const nextConfig: NextConfig = {
pathname: "/images/**",
},
],
remotePatterns: [
{
protocol: "https",
hostname: "c804387.parspack.net",
},
],
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="25.3167" height="33.0057" viewBox="0 0 25.3167 33.0057" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 33">
<path id="Vector 4" d="M12.9867 32.5209C0.144378 29.2795 0.500873 13.0729 0.501107 5.23975C7.16396 4.70558 10.6403 1.98699 13.1442 0.424162" stroke="black"/>
<path id="Vector 5" d="M12.33 32.5209C25.1723 29.2795 24.8158 13.0729 24.8156 5.23975C18.1527 4.70558 14.6764 1.98699 12.1725 0.424162" stroke="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.1788" height="32.946" viewBox="0 0 30.1788 32.946" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 41">
<path id="Ellipse 13" d="M29.6788 18.39C29.6788 26.1529 23.1469 32.446 15.0894 32.446C7.0319 32.446 0.5 26.1529 0.5 18.39C0.5 10.627 7.0319 4.33394 15.0894 4.33394M12.1715 9.10295L15.0894 4.33394L11.1989 0.349303" stroke="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 445 B

+9
View File
@@ -0,0 +1,9 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="43.7964" height="34.5421" viewBox="0 0 43.7964 34.5421" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 44">
<path id="Rectangle 70" d="M25.691 0.5H0.5V29.6788H27.7336" stroke="black"/>
<path id="Vector 6" d="M25.7891 0.500122V15.7775V22.8706" stroke="black"/>
<circle id="Ellipse 14" cx="10.2264" cy="28.7064" r="5.33576" fill="white" stroke="black"/>
<circle id="Ellipse 15" cx="33.5701" cy="28.7064" r="5.33576" fill="white" stroke="black"/>
<path id="Vector 7" d="M25.7891 7.30859H34.6245L43.2964 17.0598V29.679H39.0422" stroke="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 649 B

+7
View File
@@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="44.7676" height="35.042" viewBox="0 0 44.7676 35.042" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 39">
<path id="Rectangle 69" d="M36.4874 34.542V14.6031C36.4874 6.81417 30.1733 0.5 22.3843 0.5C14.5954 0.5 8.28125 6.81418 8.28125 14.6031V34.542" stroke="black"/>
<path id="Ellipse 11" d="M8.28102 34.542C3.98368 34.542 0.5 30.8406 0.5 26.2747C0.5 21.7087 3.98368 18.0073 8.28102 18.0073" stroke="black"/>
<path id="Ellipse 12" d="M36.4866 18.0073C40.7839 18.0073 44.2676 21.7087 44.2676 26.2747C44.2676 30.8406 40.7839 34.542 36.4866 34.542" stroke="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 670 B

+6
View File
@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.2619" height="15.8495" viewBox="0 0 16.2619 15.8495" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 34">
<path id="Vector 1" d="M8.23797 0.313273L0.626548 7.9248L8.23797 15.5362" stroke="white" stroke-width="0.886076"/>
<line id="Line 71" x1="0.785766" y1="7.76812" x2="16.2619" y2="7.76812" stroke="white" stroke-width="0.886076"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+3
View File
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="31.875" height="26.875" viewBox="0 0 31.875 26.875" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M29.6875 0H2.1875C1.60734 0 1.05094 0.230468 0.640704 0.640704C0.230468 1.05094 0 1.60734 0 2.1875V24.6875C0 25.2677 0.230468 25.8241 0.640704 26.2343C1.05094 26.6445 1.60734 26.875 2.1875 26.875H29.6875C30.2677 26.875 30.8241 26.6445 31.2343 26.2343C31.6445 25.8241 31.875 25.2677 31.875 24.6875V2.1875C31.875 1.60734 31.6445 1.05094 31.2343 0.640704C30.8241 0.230468 30.2677 0 29.6875 0ZM30 24.6875C30 24.7704 29.9671 24.8499 29.9085 24.9085C29.8499 24.9671 29.7704 25 29.6875 25H2.1875C2.10462 25 2.02513 24.9671 1.96653 24.9085C1.90792 24.8499 1.875 24.7704 1.875 24.6875V2.1875C1.875 2.10462 1.90792 2.02513 1.96653 1.96653C2.02513 1.90792 2.10462 1.875 2.1875 1.875H29.6875C29.7704 1.875 29.8499 1.90792 29.9085 1.96653C29.9671 2.02513 30 2.10462 30 2.1875V24.6875ZM23.125 7.1875C23.125 9.09374 22.3677 10.9219 21.0198 12.2698C19.6719 13.6177 17.8437 14.375 15.9375 14.375C14.0313 14.375 12.2031 13.6177 10.8552 12.2698C9.50725 10.9219 8.75 9.09374 8.75 7.1875C8.75 6.93886 8.84877 6.7004 9.02459 6.52459C9.2004 6.34877 9.43886 6.25 9.6875 6.25C9.93614 6.25 10.1746 6.34877 10.3504 6.52459C10.5262 6.7004 10.625 6.93886 10.625 7.1875C10.625 8.59646 11.1847 9.94772 12.181 10.944C13.1773 11.9403 14.5285 12.5 15.9375 12.5C17.3465 12.5 18.6977 11.9403 19.694 10.944C20.6903 9.94772 21.25 8.59646 21.25 7.1875C21.25 6.93886 21.3488 6.7004 21.5246 6.52459C21.7004 6.34877 21.9389 6.25 22.1875 6.25C22.4361 6.25 22.6746 6.34877 22.8504 6.52459C23.0262 6.7004 23.125 6.93886 23.125 7.1875Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="BookmarkSimple">
<path id="Vector" d="M17.25 3.1875H6.75C6.4019 3.1875 6.06806 3.32578 5.82192 3.57192C5.57578 3.81806 5.4375 4.1519 5.4375 4.5V21C5.43747 21.1004 5.46432 21.199 5.51527 21.2856C5.56622 21.3721 5.63941 21.4434 5.72724 21.4921C5.81506 21.5408 5.91433 21.5651 6.01472 21.5625C6.11511 21.5599 6.21296 21.5304 6.29813 21.4772L11.9991 17.9147L17.7019 21.4772C17.787 21.5304 17.8849 21.5599 17.9853 21.5625C18.0857 21.5651 18.1849 21.5408 18.2728 21.4921C18.3606 21.4434 18.4338 21.3721 18.4847 21.2856C18.5357 21.199 18.5625 21.1004 18.5625 21V4.5C18.5625 4.1519 18.4242 3.81806 18.1781 3.57192C17.9319 3.32578 17.5981 3.1875 17.25 3.1875ZM17.4375 19.9847L12.2972 16.7728C12.2078 16.7169 12.1045 16.6873 11.9991 16.6873C11.8936 16.6873 11.7903 16.7169 11.7009 16.7728L6.5625 19.9847V4.5C6.5625 4.45027 6.58225 4.40258 6.61742 4.36742C6.65258 4.33225 6.70027 4.3125 6.75 4.3125H17.25C17.2997 4.3125 17.3474 4.33225 17.3826 4.36742C17.4177 4.40258 17.4375 4.45027 17.4375 4.5V19.9847Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="CaretDown">
<path id="Vector" d="M19.8975 9.3975L12.3975 16.8975C12.292 17.0028 12.1491 17.062 12 17.062C11.8509 17.062 11.708 17.0028 11.6025 16.8975L4.1025 9.3975C4.00314 9.29087 3.94905 9.14983 3.95162 9.00411C3.95419 8.85838 4.01322 8.71934 4.11628 8.61628C4.21934 8.51322 4.35838 8.45419 4.50411 8.45162C4.64983 8.44905 4.79087 8.50314 4.8975 8.6025L12 15.7041L19.1025 8.6025C19.2091 8.50314 19.3502 8.44905 19.4959 8.45162C19.6416 8.45419 19.7807 8.51322 19.8837 8.61628C19.9868 8.71934 20.0458 8.85838 20.0484 9.00411C20.051 9.14983 19.9969 9.29087 19.8975 9.3975Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="CaretLeft">
<path id="Vector" d="M21.1716 26.2659C21.2476 26.3367 21.3085 26.4221 21.3508 26.517C21.393 26.6119 21.4158 26.7143 21.4176 26.8182C21.4194 26.922 21.4003 27.0252 21.3614 27.1215C21.3225 27.2178 21.2646 27.3053 21.1912 27.3787C21.1178 27.4521 21.0303 27.51 20.934 27.5489C20.8377 27.5878 20.7345 27.6069 20.6306 27.6051C20.5268 27.6033 20.4244 27.5805 20.3295 27.5383C20.2346 27.496 20.1492 27.4351 20.0784 27.3591L9.76594 17.0466C9.6211 16.9015 9.53974 16.705 9.53974 16.5C9.53974 16.295 9.6211 16.0985 9.76594 15.9534L20.0784 5.64094C20.2251 5.50432 20.419 5.42994 20.6194 5.43348C20.8197 5.43701 21.0109 5.51818 21.1526 5.65989C21.2943 5.8016 21.3755 5.99278 21.379 6.19315C21.3826 6.39352 21.3082 6.58744 21.1716 6.73406L11.4069 16.5L21.1716 26.2659Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 968 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="CaretLeft">
<path id="Vector" d="M21.1716 26.2659C21.2476 26.3367 21.3085 26.4221 21.3508 26.517C21.393 26.6119 21.4158 26.7143 21.4176 26.8182C21.4194 26.922 21.4003 27.0252 21.3614 27.1215C21.3225 27.2178 21.2646 27.3053 21.1912 27.3787C21.1178 27.4521 21.0303 27.51 20.934 27.5489C20.8377 27.5878 20.7345 27.6069 20.6306 27.6051C20.5268 27.6033 20.4244 27.5805 20.3295 27.5383C20.2346 27.496 20.1492 27.4351 20.0784 27.3591L9.76594 17.0466C9.6211 16.9015 9.53974 16.705 9.53974 16.5C9.53974 16.295 9.6211 16.0985 9.76594 15.9534L20.0784 5.64094C20.2251 5.50432 20.419 5.42994 20.6194 5.43348C20.8197 5.43701 21.0109 5.51818 21.1526 5.65989C21.2943 5.8016 21.3755 5.99278 21.379 6.19315C21.3826 6.39352 21.3082 6.58744 21.1716 6.73406L11.4069 16.5L21.1716 26.2659Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 968 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="CaretRight">
<path id="Vector" d="M29.5706 21.6956L16.4456 34.8206C16.259 34.9945 16.0122 35.0892 15.7572 35.0847C15.5022 35.0802 15.2589 34.9769 15.0785 34.7965C14.8981 34.6161 14.7948 34.3728 14.7903 34.1178C14.7858 33.8628 14.8805 33.616 15.0544 33.4294L27.4821 21L15.0544 8.57063C14.8805 8.38402 14.7858 8.13721 14.7903 7.88219C14.7948 7.62717 14.8981 7.38385 15.0785 7.2035C15.2589 7.02314 15.5022 6.91983 15.7572 6.91533C16.0122 6.91083 16.259 7.00549 16.4456 7.17937L29.5706 20.3044C29.755 20.4889 29.8585 20.7391 29.8585 21C29.8585 21.2609 29.755 21.5111 29.5706 21.6956Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 781 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="#0a0a0a" stroke-linecap="round" stroke-width="1.5" d="M6 6l12 12M18 6L6 18"/>
</svg>

After

Width:  |  Height:  |  Size: 197 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 5h16l-6.5 7.2V19l-3 1.5v-8.3L4 5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.75 2.25L3.75 5.25V9.75C3.75 13.05 6.05 15.9 9.75 16.5C13.45 15.9 15.75 13.05 15.75 9.75V5.25L9.75 2.25Z" stroke="#818181" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M9.75 8.25V12" stroke="#818181" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="9.75" cy="6.75" r="0.75" fill="#818181"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="#0a0a0a" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4.2h-4.2"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="221.866" height="109.766" viewBox="0 0 221.866 109.766" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 115">
<path id="Vector 12" d="M79.5508 37.0503C63.1757 36.7675 -0.600318 47.0618 2.08368 60.8086C4.92361 64.9428 10.4355 62.3236 23.2453 60.8086C44.3598 58.3113 66.3245 62.5056 68.2139 74.1019C71.5963 85.1311 42.0808 112.977 12.6644 106.911C-2.39533 103.805 10.6243 74.1019 79.5508 74.1019" stroke="white" stroke-width="4" stroke-linecap="round"/>
<path id="Vector 13" d="M97.5128 64.8795C93.4564 63.9638 79.5492 66.3815 79.5492 76.4448C79.5492 81.2156 84.185 84.1069 96.3537 72.1077C106.089 62.5084 109.681 58.5665 110.261 57.7954C104.144 65.3612 95.5656 78.1991 106.398 81.3602C117.594 84.6275 172.863 63.4827 214.627 9.48546C222.026 1.1777 210.074 -2.60288 191.281 10.9686C172.253 24.7102 162.479 37.5949 144.328 72.1077C138.266 83.6357 142.017 99.5042 156.898 92.1527C171.324 85.0269 186.08 67.3378 188.018 63.4827C195.418 65.4309 213.229 60.3264 214.627 54.1084C216.677 44.9876 200.814 43.7644 191.281 57.7954C181.748 71.8264 187.769 76.9131 198.846 74.5104C212.635 71.5194 219.866 63.4827 219.866 63.4827" stroke="white" stroke-width="4" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="28.7086" height="28.7086" viewBox="0 0 28.7086 28.7086" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M28.4821 27.2896L21.2568 20.0643C23.3172 17.6948 24.3771 14.6179 24.2132 11.4822C24.0492 8.34648 22.6741 5.39691 20.3778 3.25525C18.0815 1.11359 15.0434 -0.0528406 11.9039 0.00183914C8.76434 0.0565188 5.76868 1.32803 3.54836 3.54836C1.32803 5.76868 0.0565188 8.76434 0.00183914 11.9039C-0.0528406 15.0434 1.11359 18.0815 3.25525 20.3778C5.39691 22.6741 8.34648 24.0492 11.4822 24.2132C14.6179 24.3771 17.6948 23.3172 20.0643 21.2568L27.2896 28.4821C27.4496 28.6312 27.6611 28.7123 27.8797 28.7084C28.0983 28.7046 28.3069 28.616 28.4614 28.4614C28.616 28.3069 28.7046 28.0983 28.7084 27.8797C28.7123 27.6611 28.6312 27.4496 28.4821 27.2896ZM1.72962 12.1359C1.72962 10.0777 2.33994 8.06577 3.48339 6.35447C4.62684 4.64317 6.25208 3.30937 8.15357 2.52175C10.0551 1.73413 12.1474 1.52805 14.166 1.92957C16.1846 2.3311 18.0389 3.3222 19.4942 4.77754C20.9495 6.23288 21.9406 8.0871 22.3422 10.1057C22.7437 12.1243 22.5376 14.2167 21.75 16.1182C20.9624 18.0197 19.6286 19.6449 17.9173 20.7884C16.206 21.9318 14.194 22.5421 12.1359 22.5421C9.37699 22.5388 6.73207 21.4413 4.78124 19.4905C2.83042 17.5397 1.73297 14.8948 1.72962 12.1359Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="28.7231" height="27.5962" viewBox="0 0 28.7231 27.5962" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M28.5914 26.2998C26.3681 22.4579 22.8778 19.7621 18.8222 18.6188C20.7939 17.6165 22.3707 15.9794 23.2981 13.9713C24.2256 11.9633 24.4497 9.70135 23.9344 7.55032C23.4191 5.3993 22.1943 3.48452 20.4576 2.11474C18.7209 0.744952 16.5734 0 14.3615 0C12.1496 0 10.0022 0.744952 8.2655 2.11474C6.52878 3.48452 5.30402 5.3993 4.78868 7.55032C4.27335 9.70135 4.49748 11.9633 5.42495 13.9713C6.35241 15.9794 7.92915 17.6165 9.90091 18.6188C5.84529 19.7607 2.35498 22.4565 0.131696 26.2998C0.0706474 26.3958 0.0296556 26.5032 0.0111867 26.6155C-0.00728221 26.7278 -0.00284642 26.8426 0.0242268 26.9532C0.0513 27.0637 0.100451 27.1676 0.168724 27.2586C0.236996 27.3497 0.322979 27.426 0.4215 27.4829C0.520021 27.5399 0.629043 27.5763 0.742008 27.59C0.854973 27.6037 0.969547 27.5945 1.07884 27.5628C1.18813 27.5311 1.28987 27.4776 1.37796 27.4055C1.46604 27.3335 1.53864 27.2444 1.59138 27.1435C4.29279 22.4762 9.0656 19.6904 14.3615 19.6904C19.6575 19.6904 24.4303 22.4762 27.1317 27.1435C27.1844 27.2444 27.257 27.3335 27.3451 27.4055C27.4332 27.4776 27.535 27.5311 27.6442 27.5628C27.7535 27.5945 27.8681 27.6037 27.9811 27.59C28.094 27.5763 28.2031 27.5399 28.3016 27.4829C28.4001 27.426 28.4861 27.3497 28.5544 27.2586C28.6226 27.1676 28.6718 27.0637 28.6989 26.9532C28.7259 26.8426 28.7304 26.7278 28.7119 26.6155C28.6934 26.5032 28.6524 26.3958 28.5914 26.2998ZM6.20529 9.84665C6.20529 8.2335 6.68365 6.65657 7.57987 5.31529C8.47609 3.974 9.74992 2.92859 11.2403 2.31126C12.7306 1.69393 14.3706 1.53241 15.9527 1.84712C17.5349 2.16184 18.9882 2.93864 20.1289 4.07932C21.2696 5.21999 22.0464 6.67329 22.3611 8.25545C22.6758 9.83761 22.5143 11.4776 21.8969 12.9679C21.2796 14.4583 20.2342 15.7321 18.8929 16.6283C17.5516 17.5245 15.9747 18.0029 14.3615 18.0029C12.1992 18.0003 10.1261 17.1401 8.59708 15.6111C7.06805 14.0821 6.2079 12.009 6.20529 9.84665Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="InstagramLogo">
<path id="Vector" d="M12 7.6875C11.1471 7.6875 10.3133 7.94042 9.6041 8.41429C8.89492 8.88815 8.34217 9.56167 8.01577 10.3497C7.68937 11.1377 7.60397 12.0048 7.77036 12.8413C7.93676 13.6779 8.34749 14.4463 8.9506 15.0494C9.55372 15.6525 10.3221 16.0632 11.1587 16.2296C11.9952 16.396 12.8623 16.3106 13.6503 15.9842C14.4383 15.6578 15.1118 15.1051 15.5857 14.3959C16.0596 13.6867 16.3125 12.8529 16.3125 12C16.311 10.8567 15.8562 9.76067 15.0478 8.95225C14.2393 8.14382 13.1433 7.68899 12 7.6875ZM12 15.1875C11.3696 15.1875 10.7533 15.0006 10.2291 14.6503C9.70494 14.3001 9.29639 13.8022 9.05513 13.2198C8.81388 12.6374 8.75076 11.9965 8.87375 11.3781C8.99674 10.7598 9.30032 10.1919 9.7461 9.7461C10.1919 9.30032 10.7598 8.99674 11.3781 8.87375C11.9965 8.75076 12.6374 8.81388 13.2198 9.05513C13.8022 9.29639 14.3001 9.70494 14.6503 10.2291C15.0006 10.7533 15.1875 11.3696 15.1875 12C15.1875 12.8454 14.8517 13.6561 14.2539 14.2539C13.6561 14.8517 12.8454 15.1875 12 15.1875ZM16.5 2.4375H7.5C6.1578 2.43899 4.871 2.97284 3.92192 3.92192C2.97284 4.871 2.43899 6.1578 2.4375 7.5V16.5C2.43899 17.8422 2.97284 19.129 3.92192 20.0781C4.871 21.0272 6.1578 21.561 7.5 21.5625H16.5C17.8422 21.561 19.129 21.0272 20.0781 20.0781C21.0272 19.129 21.561 17.8422 21.5625 16.5V7.5C21.561 6.1578 21.0272 4.871 20.0781 3.92192C19.129 2.97284 17.8422 2.43899 16.5 2.4375ZM20.4375 16.5C20.4375 17.5443 20.0227 18.5458 19.2842 19.2842C18.5458 20.0227 17.5443 20.4375 16.5 20.4375H7.5C6.45571 20.4375 5.45419 20.0227 4.71577 19.2842C3.97734 18.5458 3.5625 17.5443 3.5625 16.5V7.5C3.5625 6.45571 3.97734 5.45419 4.71577 4.71577C5.45419 3.97734 6.45571 3.5625 7.5 3.5625H16.5C17.5443 3.5625 18.5458 3.97734 19.2842 4.71577C20.0227 5.45419 20.4375 6.45571 20.4375 7.5V16.5ZM17.8125 7.125C17.8125 7.31042 17.7575 7.49168 17.6545 7.64585C17.5515 7.80002 17.4051 7.92018 17.2338 7.99114C17.0625 8.06209 16.874 8.08066 16.6921 8.04449C16.5102 8.00831 16.3432 7.91902 16.2121 7.78791C16.081 7.6568 15.9917 7.48975 15.9555 7.3079C15.9193 7.12604 15.9379 6.93754 16.0089 6.76623C16.0798 6.59493 16.2 6.44851 16.3542 6.3455C16.5083 6.24248 16.6896 6.1875 16.875 6.1875C17.1236 6.1875 17.3621 6.28627 17.5379 6.46209C17.7137 6.6379 17.8125 6.87636 17.8125 7.125Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="LinkedinLogo">
<path id="Vector" d="M20.25 2.4375H3.75C3.4019 2.4375 3.06806 2.57578 2.82192 2.82192C2.57578 3.06806 2.4375 3.4019 2.4375 3.75V20.25C2.4375 20.5981 2.57578 20.9319 2.82192 21.1781C3.06806 21.4242 3.4019 21.5625 3.75 21.5625H20.25C20.5981 21.5625 20.9319 21.4242 21.1781 21.1781C21.4242 20.9319 21.5625 20.5981 21.5625 20.25V3.75C21.5625 3.4019 21.4242 3.06806 21.1781 2.82192C20.9319 2.57578 20.5981 2.4375 20.25 2.4375ZM20.4375 20.25C20.4375 20.2997 20.4177 20.3474 20.3826 20.3826C20.3474 20.4177 20.2997 20.4375 20.25 20.4375H3.75C3.70027 20.4375 3.65258 20.4177 3.61742 20.3826C3.58225 20.3474 3.5625 20.2997 3.5625 20.25V3.75C3.5625 3.70027 3.58225 3.65258 3.61742 3.61742C3.65258 3.58225 3.70027 3.5625 3.75 3.5625H20.25C20.2997 3.5625 20.3474 3.58225 20.3826 3.61742C20.4177 3.65258 20.4375 3.70027 20.4375 3.75V20.25ZM8.8125 10.5V16.5C8.8125 16.6492 8.75324 16.7923 8.64775 16.8977C8.54226 17.0032 8.39918 17.0625 8.25 17.0625C8.10082 17.0625 7.95774 17.0032 7.85225 16.8977C7.74676 16.7923 7.6875 16.6492 7.6875 16.5V10.5C7.6875 10.3508 7.74676 10.2077 7.85225 10.1023C7.95774 9.99676 8.10082 9.9375 8.25 9.9375C8.39918 9.9375 8.54226 9.99676 8.64775 10.1023C8.75324 10.2077 8.8125 10.3508 8.8125 10.5ZM17.0625 13.125V16.5C17.0625 16.6492 17.0032 16.7923 16.8977 16.8977C16.7923 17.0032 16.6492 17.0625 16.5 17.0625C16.3508 17.0625 16.2077 17.0032 16.1023 16.8977C15.9968 16.7923 15.9375 16.6492 15.9375 16.5V13.125C15.9375 12.578 15.7202 12.0534 15.3334 11.6666C14.9466 11.2798 14.422 11.0625 13.875 11.0625C13.328 11.0625 12.8034 11.2798 12.4166 11.6666C12.0298 12.0534 11.8125 12.578 11.8125 13.125V16.5C11.8125 16.6492 11.7532 16.7923 11.6477 16.8977C11.5423 17.0032 11.3992 17.0625 11.25 17.0625C11.1008 17.0625 10.9577 17.0032 10.8523 16.8977C10.7468 16.7923 10.6875 16.6492 10.6875 16.5V10.5C10.6875 10.3508 10.7468 10.2077 10.8523 10.1023C10.9577 9.99676 11.1008 9.9375 11.25 9.9375C11.3992 9.9375 11.5423 9.99676 11.6477 10.1023C11.7532 10.2077 11.8125 10.3508 11.8125 10.5V10.6978C12.2761 10.3044 12.8425 10.0518 13.4449 9.96973C14.0474 9.8877 14.6607 9.97968 15.2126 10.2348C15.7645 10.49 16.2319 10.8976 16.5597 11.4097C16.8874 11.9218 17.0619 12.517 17.0625 13.125ZM9.1875 7.875C9.1875 8.06042 9.13252 8.24168 9.0295 8.39585C8.92649 8.55002 8.78007 8.67018 8.60877 8.74114C8.43746 8.81209 8.24896 8.83066 8.0671 8.79449C7.88525 8.75831 7.7182 8.66902 7.58709 8.53791C7.45598 8.4068 7.36669 8.23975 7.33051 8.0579C7.29434 7.87604 7.31291 7.68754 7.38386 7.51623C7.45482 7.34493 7.57498 7.19851 7.72915 7.0955C7.88332 6.99248 8.06458 6.9375 8.25 6.9375C8.49864 6.9375 8.7371 7.03627 8.91291 7.21209C9.08873 7.3879 9.1875 7.62636 9.1875 7.875Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="TelegramLogo">
<path id="Vector" d="M21.3347 2.59688C21.2442 2.51875 21.1342 2.46681 21.0164 2.44665C20.8986 2.4265 20.7775 2.43889 20.6663 2.4825L1.66688 9.91781C1.43578 10.0082 1.24026 10.1712 1.10968 10.3822C0.979102 10.5932 0.920507 10.8409 0.942697 11.0881C0.964888 11.3352 1.06667 11.5685 1.23275 11.7529C1.39883 11.9373 1.62025 12.0628 1.86375 12.1106L6.9375 13.1072V18.75C6.93761 19.0116 7.01588 19.2672 7.16228 19.484C7.30869 19.7008 7.51653 19.8689 7.75915 19.9667C8.00177 20.0646 8.26809 20.0877 8.52393 20.0331C8.77977 19.9785 9.01346 19.8487 9.195 19.6603L11.6925 17.07L15.5906 20.4872C15.8286 20.6978 16.1353 20.8141 16.4531 20.8144C16.5919 20.8141 16.7298 20.7922 16.8619 20.7497C17.0784 20.6812 17.2732 20.5571 17.4269 20.3898C17.5805 20.2225 17.6876 20.0178 17.7375 19.7963L21.5456 3.24094C21.5724 3.12455 21.5669 3.00306 21.5297 2.88957C21.4926 2.77607 21.4251 2.67488 21.3347 2.59688ZM2.06719 11.0034C2.0636 10.9938 2.0636 10.9831 2.06719 10.9734C2.07142 10.9702 2.07618 10.9676 2.08125 10.9659L17.0541 5.10469L7.36969 12.0422L2.08125 11.0072L2.06719 11.0034ZM8.385 18.8784C8.35916 18.9052 8.32592 18.9238 8.28953 18.9316C8.25313 18.9395 8.21521 18.9363 8.18062 18.9225C8.14603 18.9087 8.11632 18.885 8.09528 18.8543C8.07425 18.8235 8.06283 18.7872 8.0625 18.75V13.8853L10.8459 16.3228L8.385 18.8784ZM16.6416 19.5422C16.6347 19.5739 16.6193 19.6031 16.5972 19.6269C16.5751 19.6506 16.5471 19.668 16.5159 19.6772C16.4842 19.6883 16.45 19.6907 16.417 19.6841C16.3841 19.6775 16.3535 19.6622 16.3284 19.6397L8.40375 12.6881L20.1563 4.26563L16.6416 19.5422Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="WhatsappLogo">
<path id="Vector" d="M17.5013 13.7466L14.5013 12.2466C14.4126 12.2025 14.314 12.1826 14.2152 12.1888C14.1165 12.1951 14.0211 12.2273 13.9387 12.2822L12.4688 13.2619C11.7246 12.8809 11.1191 12.2754 10.7381 11.5312L11.7188 10.0622C11.7736 9.97985 11.8058 9.88448 11.8121 9.78572C11.8184 9.68696 11.7984 9.58829 11.7544 9.49969L10.2544 6.49969C10.2077 6.40583 10.1358 6.32687 10.0467 6.27171C9.95757 6.21655 9.85481 6.18738 9.75 6.1875C8.80517 6.1875 7.89903 6.56283 7.23093 7.23093C6.56283 7.89903 6.1875 8.80517 6.1875 9.75C6.18998 11.8875 7.04022 13.9368 8.55169 15.4483C10.0632 16.9598 12.1125 17.81 14.25 17.8125C15.1948 17.8125 16.101 17.4372 16.7691 16.7691C17.4372 16.101 17.8125 15.1948 17.8125 14.25C17.8125 14.1454 17.7834 14.043 17.7284 13.954C17.6735 13.8651 17.5948 13.7933 17.5013 13.7466ZM14.25 16.6875C12.4107 16.6853 10.6475 15.9536 9.34691 14.6531C8.04636 13.3525 7.31473 11.5893 7.3125 9.75C7.31255 9.16171 7.52536 8.5933 7.91166 8.14962C8.29797 7.70594 8.83169 7.41694 9.41438 7.33594L10.6013 9.70969L9.62719 11.1703C9.57601 11.2473 9.54461 11.3358 9.53578 11.4279C9.52695 11.5199 9.54096 11.6128 9.57656 11.6981C10.0938 12.9279 11.0721 13.9062 12.3019 14.4234C12.3872 14.459 12.4801 14.473 12.5721 14.4642C12.6642 14.4554 12.7527 14.424 12.8297 14.3728L14.2903 13.3987L16.6641 14.5856C16.5831 15.1683 16.2941 15.702 15.8504 16.0883C15.4067 16.4746 14.8383 16.6874 14.25 16.6875ZM12 2.4375C10.3423 2.43715 8.71298 2.86774 7.2719 3.68703C5.83081 4.50632 4.62745 5.68616 3.77989 7.11081C2.93233 8.53545 2.46967 10.156 2.43731 11.8133C2.40496 13.4707 2.804 15.108 3.59531 16.5647L2.50594 19.8337C2.42882 20.065 2.41762 20.3132 2.47362 20.5504C2.52961 20.7877 2.65057 21.0047 2.82295 21.1771C2.99533 21.3494 3.2123 21.4704 3.44957 21.5264C3.68683 21.5824 3.93499 21.5712 4.16625 21.4941L7.43531 20.4047C8.71498 21.0991 10.1363 21.4922 11.591 21.5539C13.0456 21.6156 14.4952 21.3443 15.8291 20.7608C17.1629 20.1772 18.346 19.2968 19.2879 18.1866C20.2299 17.0764 20.9059 15.7657 21.2644 14.3546C21.6229 12.9435 21.6544 11.4691 21.3566 10.044C21.0588 8.61879 20.4395 7.28044 19.5459 6.13097C18.6523 4.98149 17.508 4.05125 16.2003 3.41117C14.8926 2.77108 13.456 2.43806 12 2.4375ZM12 20.4375C10.5168 20.4379 9.05974 20.0472 7.77563 19.305C7.69002 19.2558 7.59311 19.2296 7.49438 19.2291C7.43385 19.2294 7.37375 19.2392 7.31625 19.2581L3.81094 20.4263C3.7779 20.4373 3.74245 20.4389 3.70855 20.4309C3.67466 20.4229 3.64366 20.4056 3.61904 20.381C3.59441 20.3563 3.57713 20.3253 3.56913 20.2914C3.56113 20.2576 3.56273 20.2221 3.57375 20.1891L4.74188 16.6875C4.76715 16.6118 4.77604 16.5317 4.76796 16.4523C4.75988 16.3729 4.73501 16.2962 4.695 16.2272C3.7643 14.6195 3.3902 12.7495 3.63074 10.9075C3.87128 9.06544 4.71301 7.35427 6.02534 6.03945C7.33767 4.72463 9.04724 3.87965 10.8888 3.63562C12.7304 3.39159 14.601 3.76213 16.2105 4.68978C17.82 5.61742 19.0784 7.05031 19.7904 8.76612C20.5024 10.4819 20.6282 12.3848 20.1484 14.1794C19.6685 15.9741 18.6098 17.5602 17.1365 18.6917C15.6632 19.8232 13.8577 20.4369 12 20.4375Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import AboutSection from "@/components/home/AboutSection";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import { ABOUT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
export const metadata: Metadata = {
title: "درباره ما | مانا کالکشن",
description: ABOUT_PAGE.lead,
};
export default async function AboutPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={ABOUT_PAGE.titleFa}
titleEn={ABOUT_PAGE.titleEn}
heroImage={home.heroSlides[0]?.image || ABOUT_PAGE.heroImage}
breadcrumbs={ABOUT_PAGE.breadcrumbs}
/>
</div>
<AboutSection
titleFa={home.about.titleFa}
titleEn={home.about.titleEn}
lead={home.about.lead}
body={home.about.body}
image={home.about.image}
imageSecondary={home.about.imageSecondary}
/>
<section className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
{ABOUT_PAGE.storyTitleFa}
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{ABOUT_PAGE.storyTitleEn}
</p>
<p className="mt-6 text-sm leading-8 text-ink/80">
{ABOUT_PAGE.story}
</p>
<Link
href="/contact"
className="mt-8 inline-flex border border-ink px-5 py-2.5 text-sm transition-colors hover:bg-ink hover:text-white"
>
تماس با ما
</Link>
</div>
<ul className="space-y-6">
{ABOUT_PAGE.values.map((value) => (
<li
key={value.titleEn}
className="border border-line bg-[#f7f7f5] p-5"
>
<p className="text-base text-ink">{value.titleFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{value.titleEn}
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
{value.body}
</p>
</li>
))}
</ul>
</div>
<div className="relative mt-12 aspect-[21/9] overflow-hidden bg-[#f3f3f1]">
<Image
src={home.about.imageSecondary || ABOUT_PAGE.imageSecondary}
alt=""
fill
sizes="100vw"
className="object-cover"
/>
</div>
</section>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+164
View File
@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import { getCategoryPage } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
type ApiCategoryNode,
} from "@/lib/categories-api";
import { categoryPath, categorySlug } from "@/lib/category-path";
type PageProps = {
params: Promise<{ path: string[] }>;
};
function findCategory(
nodes: ApiCategoryNode[],
id: string,
): ApiCategoryNode | null {
for (const node of nodes) {
if (String(node.id) === id) return node;
const child = findCategory(node.children ?? [], id);
if (child) return child;
}
return null;
}
async function resolveCategory(id: string) {
const apiCategories = await fetchPublicCategories();
const apiCat = findCategory(apiCategories, id);
if (apiCat) {
return {
apiCategories,
apiCat,
staticCat: null as ReturnType<typeof getCategoryPage>,
id: apiCat.id,
nameFa: apiCat.nameFa,
nameEn: apiCat.nameEn || apiCat.nameFa,
imageUrl: apiCat.imageUrl,
description: `مجموعه‌ای از محصولات ${apiCat.nameFa} در مانا کالکشن.`,
};
}
const staticCat = getCategoryPage(id);
if (staticCat) {
return {
apiCategories,
apiCat: null,
staticCat,
id: staticCat.id,
nameFa: staticCat.nameFa,
nameEn: staticCat.nameEn,
imageUrl: null as string | null,
description: staticCat.description,
};
}
return null;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { path } = await params;
const id = path?.[0];
if (!id) return { title: "دسته | مانا کالکشن" };
const resolved = await resolveCategory(id);
if (!resolved) return { title: "دسته | مانا کالکشن" };
return {
title: `${resolved.nameFa} | مانا کالکشن`,
description: resolved.description,
};
}
export default async function CategoryPage({ params }: PageProps) {
const { path } = await params;
if (!path?.length || path.length > 2) notFound();
const [id, slug] = path;
const resolved = await resolveCategory(id);
if (!resolved) notFound();
const expectedSlug = categorySlug({
id: resolved.id,
nameEn: resolved.nameEn,
nameFa: resolved.nameFa,
});
const canonical = categoryPath({
id: resolved.id,
nameEn: resolved.nameEn,
nameFa: resolved.nameFa,
});
// Legacy `/categories/:id` or wrong slug → canonical `/categories/:id/:slug`
if (path.length === 1 || slug !== expectedSlug) {
redirect(canonical);
}
const [slotItems, products] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts(
resolved.apiCat ? { categoryId: resolved.id } : undefined,
),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
resolved.apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={resolved.nameFa}
titleEn={resolved.nameEn}
heroImage={
home.heroSlides[0]?.image ||
resolved.imageUrl ||
resolved.staticCat?.heroImage
}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "دسته‌بندی‌ها", href: "/categories" },
{ label: resolved.nameFa },
]}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 pt-8 sm:px-6 lg:px-8">
<p className="max-w-2xl text-sm leading-7 text-ink/75">
{resolved.description}
</p>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[48px]">
<ProductFilters />
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
</div>
</div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
} from "@/lib/categories-api";
import { categoryPath } from "@/lib/category-path";
export const metadata: Metadata = {
title: "دسته‌بندی‌ها | مانا کالکشن",
description: "مرور دسته‌بندی‌های مانا کالکشن",
};
export default async function CategoriesIndexPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const list = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa="دسته‌بندی‌ها"
titleEn="Categories"
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "دسته‌بندی‌ها" },
]}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3 lg:gap-6">
{list.map((cat) => (
<li key={cat.id}>
<Link
href={cat.href || categoryPath(cat)}
className="group block overflow-hidden rounded-[5px] border border-line bg-white transition-colors hover:border-ink"
>
<div className="relative aspect-[4/5] overflow-hidden bg-[#f6f6f4]">
<Image
src={cat.image}
alt={cat.nameFa}
fill
sizes="(max-width: 768px) 50vw, 360px"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
<div className="flex items-end justify-between gap-3 px-4 py-3">
<div>
<p className="text-base text-ink">{cat.nameFa}</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
{cat.nameEn}
</p>
</div>
<span className="text-xs text-muted transition-colors group-hover:text-ink">
مشاهده
</span>
</div>
</Link>
</li>
))}
</ul>
</div>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ContactForm from "@/components/contact/ContactForm";
import { CONTACT_PAGE } from "@/data/pages";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
export const metadata: Metadata = {
title: "تماس با ما | مانا کالکشن",
description: CONTACT_PAGE.lead,
};
export default async function ContactPage() {
const [slotItems, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={CONTACT_PAGE.titleFa}
titleEn={CONTACT_PAGE.titleEn}
heroImage={home.heroSlides[0]?.image || CONTACT_PAGE.heroImage}
breadcrumbs={CONTACT_PAGE.breadcrumbs}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:gap-14">
<aside className="space-y-6 border border-line bg-[#f7f7f5] p-6 sm:p-8">
<div>
<h2 className="font-doran text-2xl font-light text-ink">
اطلاعات تماس
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Get in touch
</p>
</div>
<dl className="space-y-4 text-sm leading-7">
<div>
<dt className="text-muted">آدرس</dt>
<dd className="text-ink">{CONTACT_PAGE.address}</dd>
</div>
<div>
<dt className="text-muted">تلفن فروشگاه</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.phoneStore}
</dd>
</div>
<div>
<dt className="text-muted">تلفن همراه</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.phoneMobile}
</dd>
</div>
<div>
<dt className="text-muted">ایمیل</dt>
<dd dir="ltr" className="font-montserrat text-ink">
{CONTACT_PAGE.email}
</dd>
</div>
<div>
<dt className="text-muted">ساعات پاسخگویی</dt>
<dd className="text-ink">{CONTACT_PAGE.hours}</dd>
</div>
</dl>
</aside>
<div>
<h2 className="font-doran text-2xl font-light text-ink">
فرم تماس
</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Send a message
</p>
<div className="mt-6">
<ContactForm />
</div>
</div>
</div>
</div>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+80 -2
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
:root {
--background: #fbfbfa;
--background: #ffffff;
--foreground: #0a0a0a;
--surface: #ffffff;
--muted: #6b6b6b;
@@ -16,13 +16,17 @@
--color-muted: var(--muted);
--color-line: var(--line);
--color-ink: var(--ink);
--font-sans: var(--font-doran);
--font-sans: var(--font-iranyekan);
--font-display: var(--font-cailyne);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-iranyekan), Tahoma, sans-serif;
}
.font-doran {
font-family: var(--font-doran), Tahoma, sans-serif;
}
@@ -35,6 +39,10 @@ body {
font-family: var(--font-montserrat), system-ui, sans-serif;
}
.home-page {
overflow-x: clip;
}
@keyframes coming-soon-rise {
from {
opacity: 0;
@@ -157,6 +165,76 @@ body {
}
}
@keyframes hero-ken-burns {
from {
transform: scale(1);
}
to {
transform: scale(1.06);
}
}
.hero-ken-burns {
animation: hero-ken-burns 10s linear forwards;
will-change: transform;
transform-origin: center center;
}
.hero-ken-burns-paused {
animation-play-state: paused;
}
.seq-fade-item {
transition:
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
@media (prefers-reduced-motion: reduce) {
.seq-fade-item {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
@keyframes hero-copy-rise {
from {
opacity: 0;
transform: translateY(18px);
filter: blur(6px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.hero-copy-item {
opacity: 0;
animation: hero-copy-rise 0.75s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.hero-copy-item-2 {
animation-delay: 0.12s;
}
.hero-copy-item-3 {
animation-delay: 0.24s;
}
@media (prefers-reduced-motion: reduce) {
.hero-ken-burns,
.hero-copy-item {
animation: none !important;
opacity: 1 !important;
filter: none !important;
transform: none !important;
}
}
.mana-canvas {
background:
radial-gradient(ellipse 80% 60% at 20% 20%, rgba(0, 0, 0, 0.02), transparent 55%),
+46 -3
View File
@@ -3,6 +3,48 @@ import { Montserrat } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
const iranYekan = localFont({
src: [
{
path: "../fonts/iranyekan/IRANYekanThinFaNum.ttf",
weight: "100",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanLightFaNum.ttf",
weight: "300",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanRegularFaNum.ttf",
weight: "400",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanMediumFaNum.ttf",
weight: "500",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBoldFaNum.ttf",
weight: "700",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanExtraBoldFaNum.ttf",
weight: "800",
style: "normal",
},
{
path: "../fonts/iranyekan/IRANYekanBlackFaNum.ttf",
weight: "900",
style: "normal",
},
],
variable: "--font-iranyekan",
display: "swap",
});
const doran = localFont({
src: [
{
@@ -50,8 +92,9 @@ const montserrat = Montserrat({
});
export const metadata: Metadata = {
title: "به‌زودی | مانا کالکشن",
description: "فروشگاه آنلاین مانا کالکشن به‌زودی در دسترس خواهد بود.",
title: "مانا کالکشن | Mana Collection",
description:
"فروشگاه آنلاین مانا کالکشن — پوشاک زنانه، کالکشن پاییزه و انتخاب‌های خاص برای بانویی که خودش را انتخاب می‌کند.",
};
export default function RootLayout({
@@ -63,7 +106,7 @@ export default function RootLayout({
<html
lang="fa"
dir="rtl"
className={`${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
className={`${iranYekan.variable} ${doran.variable} ${cailyne.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full font-sans text-foreground">{children}</body>
</html>
+102 -74
View File
@@ -1,81 +1,109 @@
import Image from "next/image";
import ManaLogo from "@/components/ui/ManaLogo";
import SiteHeader from "@/components/home/SiteHeader";
import HeroSlider from "@/components/home/HeroSlider";
import CategoryStrip from "@/components/home/CategoryStrip";
import ProductRow from "@/components/home/ProductRow";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import FeaturesBar from "@/components/home/FeaturesBar";
import BlackCollection from "@/components/home/BlackCollection";
import AboutSection from "@/components/home/AboutSection";
import SiteFooter from "@/components/home/SiteFooter";
import { mapHomeSlots } from "@/lib/map-home-slots";
import {
fetchPublicWebsiteSlots,
itemsForSlot,
} from "@/lib/website-slots";
import { getStorefrontCatalog } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
mapCategoriesToStrip,
} from "@/lib/categories-api";
const STORE_LOGIN =
process.env.NEXT_PUBLIC_STORE_URL?.replace(/\/$/, "") ||
"http://store.mana-collection.com:3001";
export default async function HomePage() {
const [slotItems, catalog, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontCatalog(),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const saleProducts = catalog.promo1;
const discountProducts = catalog.promo2;
const autumnProducts = catalog.promo3;
const stripCategories = mapCategoriesToStrip(apiCategories);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({
image: t.image,
nameFa: t.nameFa,
href: t.href,
})),
);
// Prefer Website Images → category-strip slots over catalog categories
const hasWebsiteCategoryStrip =
itemsForSlot(slotItems, "category-strip").length > 0;
const categories = hasWebsiteCategoryStrip
? home.categories
: stripCategories.length
? stripCategories
: home.categories;
export default function HomePage() {
return (
<main className="coming-soon relative min-h-dvh overflow-hidden bg-[#f7f7f5] text-ink">
{/* dir=ltr keeps content on visual left, image on visual right */}
<div dir="ltr" className="grid min-h-dvh grid-cols-1 lg:grid-cols-2">
{/* Content — left */}
<section className="relative order-2 flex min-h-[52dvh] flex-col lg:order-1 lg:min-h-dvh">
<div className="mana-canvas absolute inset-0" aria-hidden />
<div
dir="rtl"
className="relative z-10 flex flex-1 flex-col px-7 py-8 sm:px-12 sm:py-10 lg:px-16 lg:py-14"
>
<header className="coming-soon-seq coming-soon-seq-1 flex shrink-0 justify-center">
<ManaLogo
href=""
width={400}
priority
showTagline
variant="white"
blackBg
className="w-[400px] max-w-[min(400px,100%)]"
/>
</header>
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center sm:py-16">
<div className="max-w-md">
<p
dir="ltr"
className="coming-soon-seq coming-soon-seq-2 mb-4 font-display text-sm tracking-[0.2em] text-muted sm:text-base"
>
Coming soon
</p>
<h1 className="coming-soon-seq coming-soon-seq-3 mb-5 text-4xl leading-tight text-ink sm:text-5xl">
به‌زودی
</h1>
<p className="coming-soon-seq coming-soon-seq-4 text-sm leading-8 text-muted sm:text-base sm:leading-8">
در این آدرس میزبان شما خواهیم بود.
</p>
</div>
</div>
<footer className="coming-soon-seq coming-soon-seq-5 shrink-0 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="text-xs leading-6 text-muted">
حساب دارید؟ وارد شوید.
</p>
<a
href={`${STORE_LOGIN}/login`}
className="coming-soon-login inline-flex items-center justify-center border border-ink bg-ink px-7 py-2.5 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
ورود
</a>
</div>
</footer>
</div>
</section>
{/* Image — right */}
<aside className="coming-soon-seq-image relative order-1 min-h-[42dvh] overflow-hidden lg:order-2 lg:min-h-dvh">
<Image
src="/images/image1.jpg"
alt="مانا کالکشن"
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="coming-soon-image object-cover object-center"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-black/10" />
</aside>
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<HeroSlider slides={home.heroSlides} />
</div>
<CategoryStrip categories={categories} />
<div className="flex flex-col gap-10 lg:gap-14">
<ProductRow
products={saleProducts}
promo="sale"
stagger
promoSlot={home.promoSale}
/>
<div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
</div>
<ProductRow
products={discountProducts}
promo="discount"
stagger
promoSlot={home.promoDiscount}
/>
<FeaturesBar />
<BlackCollection items={home.blackItems} />
<ProductRow
products={autumnProducts}
promo="autumn"
stagger
promoSlot={home.promoAutumn}
/>
</div>
<AboutSection
titleFa={home.about.titleFa}
titleEn={home.about.titleEn}
lead={home.about.lead}
body={home.about.body}
image={home.about.image}
imageSecondary={home.about.imageSecondary}
/>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import ProductDetailHero from "@/components/product-detail/ProductDetailHero";
import ProductGallery from "@/components/product-detail/ProductGallery";
import ProductBuyBox from "@/components/product-detail/ProductBuyBox";
import ProductTabs from "@/components/product-detail/ProductTabs";
import SimilarProducts from "@/components/product-detail/SimilarProducts";
import { getProductDetail } from "@/data/product-detail";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import {
fetchPublicProduct,
getStorefrontProducts,
mapApiProductToDetail,
} from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
import { productPath, productSlug } from "@/lib/product-path";
type PageProps = {
params: Promise<{ path: string[] }>;
};
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { path } = await params;
const id = path?.[0];
if (!id) return { title: "محصول | مانا کالکشن" };
const api = await fetchPublicProduct(id);
if (api) {
return {
title: `${api.nameFa} | مانا کالکشن`,
description: api.summary || api.description || api.nameFa,
};
}
const fallback = getProductDetail(id);
if (!fallback) return { title: "محصول | مانا کالکشن" };
return {
title: `${fallback.nameFa} | مانا کالکشن`,
description: fallback.summary,
};
}
export default async function ProductDetailPage({ params }: PageProps) {
const { path } = await params;
if (!path?.length || path.length > 2) notFound();
const [id, slug] = path;
const [slotItems, apiProduct, similar, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
fetchPublicProduct(id),
getStorefrontProducts(),
fetchPublicCategories(),
]);
const product = apiProduct
? mapApiProductToDetail(apiProduct)
: getProductDetail(id);
if (!product) notFound();
const expectedSlug = productSlug({
id: product.id,
nameEn: product.nameEn,
nameFa: product.nameFa,
});
const canonical = productPath({
id: product.id,
nameEn: product.nameEn,
nameFa: product.nameFa,
});
// Legacy `/products/:id` or wrong slug → canonical `/products/:id/:slug`
if (path.length === 1 || slug !== expectedSlug) {
redirect(canonical);
}
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
const similarCards = similar
.filter((p) => p.id !== product.id)
.slice(0, 8);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductDetailHero
heroImage={home.heroSlides[0]?.image || undefined}
breadcrumbs={product.breadcrumbs}
/>
</div>
<div className="mx-auto max-w-[1200px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8 lg:py-12">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-14 lg:items-start">
<ProductGallery images={product.gallery} alt={product.nameFa} />
<ProductBuyBox product={product} />
</div>
<div className="mt-12 space-y-14 lg:mt-16 lg:space-y-16">
<ProductTabs product={product} />
<SimilarProducts
products={
similarCards.length
? similarCards
: similar.slice(0, 8)
}
/>
</div>
</div>
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
import SiteHeader from "@/components/home/SiteHeader";
import SiteFooter from "@/components/home/SiteFooter";
import LeatherBanner from "@/components/home/LeatherBanner";
import PartnerCta from "@/components/home/PartnerCta";
import ProductListHero from "@/components/products/ProductListHero";
import ProductFilters from "@/components/products/ProductFilters";
import ProductGrid from "@/components/products/ProductGrid";
import ProductPagination from "@/components/products/ProductPagination";
import { LIST_PAGE_META } from "@/data/products";
import { mapHomeSlots } from "@/lib/map-home-slots";
import { fetchPublicWebsiteSlots } from "@/lib/website-slots";
import { getStorefrontProducts } from "@/lib/products-api";
import {
fetchPublicCategories,
mapCategoriesToMegaTabs,
} from "@/lib/categories-api";
type PageProps = {
searchParams: Promise<{ q?: string; cat?: string }>;
};
export default async function ProductsPage({ searchParams }: PageProps) {
const params = await searchParams;
const [slotItems, products, apiCategories] = await Promise.all([
fetchPublicWebsiteSlots(),
getStorefrontProducts({
name: params.q,
categoryId: params.cat,
}),
fetchPublicCategories(),
]);
const home = mapHomeSlots(slotItems);
const megaTabs = mapCategoriesToMegaTabs(
apiCategories,
home.megaTabs.map((t) => ({ image: t.image })),
);
return (
<main className="home-page min-h-dvh bg-white text-ink">
<div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<ProductListHero
titleFa={params.q ? `جستجو: ${params.q}` : LIST_PAGE_META.titleFa}
titleEn={params.q ? "Search results" : LIST_PAGE_META.titleEn}
heroImage={home.heroSlides[0]?.image || LIST_PAGE_META.heroImage}
breadcrumbs={
params.q
? [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "جستجو" },
]
: LIST_PAGE_META.breadcrumbs
}
/>
</div>
<div className="mt-6 space-y-6 sm:mt-8 lg:mt-[60px]">
<ProductFilters />
<div className="space-y-3 sm:space-y-4">
<ProductGrid products={products} />
<ProductPagination />
</div>
</div>
<LeatherBanner
image={home.leather.image}
nameFa={home.leather.nameFa}
nameEn={home.leather.nameEn}
linkText={home.leather.linkText}
link={home.leather.link}
/>
<PartnerCta />
<SiteFooter paymentBadges={home.paymentBadges} />
</main>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { FormEvent, useState } from "react";
import { CONTACT_PAGE } from "@/data/pages";
export default function ContactForm() {
const [sent, setSent] = useState(false);
const [saving, setSaving] = useState(false);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
window.setTimeout(() => {
setSaving(false);
setSent(true);
}, 500);
}
if (sent) {
return (
<div className="border border-line bg-[#f7f7f5] px-5 py-8 text-center">
<p className="font-doran text-xl text-ink">پیام شما دریافت شد</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Message received
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
به‌زودی با شما تماس می‌گیریم.
</p>
<button
type="button"
className="mt-5 border border-ink px-4 py-2 text-sm text-ink hover:bg-ink hover:text-white"
onClick={() => setSent(false)}
>
ارسال پیام دیگر
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm leading-7 text-ink/75">{CONTACT_PAGE.lead}</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs text-muted">نام</span>
<input
required
name="name"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="نام شما"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">شماره تماس</span>
<input
required
name="phone"
dir="ltr"
inputMode="tel"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="09xxxxxxxxx"
/>
</label>
</div>
<label className="block space-y-1.5">
<span className="text-xs text-muted">ایمیل (اختیاری)</span>
<input
name="email"
type="email"
dir="ltr"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="you@email.com"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">موضوع</span>
<input
required
name="subject"
className="w-full border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="پیگیری سفارش، سوال سایز، همکاری..."
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs text-muted">پیام</span>
<textarea
required
name="message"
rows={5}
className="w-full resize-y border border-line bg-white px-3 py-2.5 text-sm outline-none focus:border-ink"
placeholder="متن پیام خود را بنویسید..."
/>
</label>
<button
type="submit"
disabled={saving}
className="group relative inline-flex h-12 w-full items-center justify-center overflow-hidden rounded-[5px] border border-ink bg-ink text-white sm:w-auto sm:min-w-[12rem]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-white transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<span className="relative z-10 text-sm transition-colors duration-[400ms] group-hover:text-ink">
{saving ? "..." : "ارسال پیام"}
</span>
</button>
</form>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Image from "next/image";
import { ABOUT } from "@/data/home";
type AboutSectionProps = {
titleFa?: string;
titleEn?: string;
lead?: string;
body?: string;
image?: string;
imageSecondary?: string;
};
export default function AboutSection({
titleFa = ABOUT.titleFa,
titleEn = ABOUT.titleEn,
lead = ABOUT.lead,
body = ABOUT.body,
image = ABOUT.image,
imageSecondary = ABOUT.imageSecondary,
}: AboutSectionProps) {
return (
<section id="about" dir="rtl" className="scroll-mt-24 bg-white py-16 lg:py-24">
<div className="mx-auto grid max-w-[1200px] items-center gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:gap-16 lg:px-8">
<div className="relative mx-auto w-full max-w-[520px] lg:mx-0">
<div
aria-hidden
className="absolute -inset-x-4 top-8 bottom-0 -z-0 rounded-[5px] border border-ink/15 bg-[#f3f3f3] sm:-inset-x-6 lg:inset-x-0 lg:-start-8 lg:end-12 lg:top-12"
style={{
backgroundImage:
"radial-gradient(circle, rgba(10,10,10,0.14) 1px, transparent 1.2px)",
backgroundSize: "14px 14px",
}}
/>
<div className="relative z-10 aspect-[4/5] w-[78%] overflow-hidden rounded-[5px] bg-[#ebe6df]">
<Image
src={image}
alt={titleFa}
fill
sizes="(max-width: 1024px) 70vw, 380px"
className="object-cover object-[center_18%]"
/>
</div>
<div className="absolute bottom-0 end-0 z-20 aspect-[3/4] w-[48%] overflow-hidden rounded-[5px] border border-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
<Image
src={imageSecondary}
alt=""
fill
sizes="(max-width: 1024px) 40vw, 240px"
className="object-cover object-top"
/>
</div>
</div>
<div className="relative text-right">
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute -top-4 start-0 font-doran text-[clamp(2rem,5vw,3.5rem)] font-normal tracking-[0.08em] text-ink/10"
>
{titleEn}
</p>
<h2 className="font-doran relative text-[clamp(1.75rem,3vw,2.5rem)] font-light text-ink">
{titleFa}
</h2>
<p className="font-doran mt-4 text-base font-medium text-ink sm:text-lg">
{lead}
</p>
<p className="mt-4 text-sm font-light leading-8 text-ink/75 sm:text-[15px] sm:leading-8">
{body}
</p>
</div>
</div>
</section>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { BLACK_ITEMS } from "@/data/home";
export type BlackItemData = {
id: string;
image: string;
alt: string;
nameFa: string;
nameEn: string;
href: string;
};
type BlackCollectionProps = {
items?: BlackItemData[];
};
export default function BlackCollection({ items }: BlackCollectionProps) {
const list: BlackItemData[] =
items?.length
? items
: BLACK_ITEMS.map((item) => ({
id: item.id,
image: item.image,
alt: item.alt,
nameFa: item.nameFa,
nameEn: item.nameEn,
href: `/products?color=black&item=${item.id}`,
}));
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section
ref={ref}
className="relative mx-auto w-full max-w-[1710px] px-4 py-10 sm:px-6 lg:px-[120px] lg:py-14"
>
<div
className="seq-fade-item relative mb-8 flex items-center justify-center text-center lg:mb-10"
style={{
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
}}
>
<p
dir="ltr"
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center font-doran text-[clamp(2.5rem,8vw,6.25rem)] font-normal leading-none tracking-[0.1em] text-ink/10"
>
i Like it Black
</p>
<h2 className="font-doran relative translate-y-[0.12em] text-[clamp(1.75rem,4vw,3.125rem)] font-light leading-none text-ink">
من عاشق رنگ <span className="font-medium">مشکی</span> هستم
</h2>
</div>
<ul className="grid grid-cols-2 gap-[14px] lg:grid-cols-4 lg:gap-[22px]">
{list.map((item, i) => (
<li
key={item.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${(i + 1) * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}}
>
<Link
href={item.href}
className="group relative block aspect-[410/717] overflow-hidden bg-[#f3f3f3]"
>
<Image
src={item.image}
alt={item.alt}
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
<div className="absolute inset-x-3 bottom-3 z-10 overflow-hidden bg-white px-3 py-2.5 text-center sm:inset-x-4 sm:bottom-4 sm:px-4 sm:py-3">
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<p className="relative z-10 font-doran text-base font-medium text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg lg:text-xl">
{item.nameFa}
</p>
<p
dir="ltr"
className="relative z-10 mt-0.5 font-montserrat text-[10px] font-light uppercase tracking-[0.28em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-[11px]"
>
{item.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { CATEGORIES, type Category } from "@/data/home";
type CategoryStripProps = {
categories?: Category[];
};
export default function CategoryStrip({ categories }: CategoryStripProps) {
const list = categories?.length ? categories : CATEGORIES;
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1600px] px-4 py-20 sm:px-6 sm:py-24 lg:px-10 lg:py-32">
<ul
ref={ref}
className="flex flex-wrap items-stretch justify-center gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8"
>
{list.map((category, i) => (
<li
key={category.id}
className="seq-fade-item"
style={{
transitionDelay: visible ? `${i * 90}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Link
href={category.href ?? "#"}
className="group relative flex w-[140px] flex-col items-center gap-2 overflow-hidden px-3 py-5 text-center sm:w-[160px] sm:px-4 sm:py-6 lg:w-[180px]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-ink transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<div className="relative z-10 h-[72px] w-[68px] overflow-hidden sm:h-[80px] sm:w-[76px]">
<Image
src={category.image}
alt={category.nameFa}
fill
sizes="76px"
className="object-contain transition-[filter,transform] duration-[400ms] ease-out group-hover:invert"
/>
</div>
<div className="relative z-10 w-full">
<p className="whitespace-nowrap text-base font-light text-ink transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-lg">
{category.nameFa}
</p>
<p
dir="ltr"
className="whitespace-nowrap font-montserrat text-[11px] font-light uppercase tracking-[0.35em] text-[#898989] transition-colors duration-[400ms] ease-out group-hover:text-white sm:text-xs"
>
{category.nameEn}
</p>
</div>
</Link>
</li>
))}
</ul>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { FEATURES } from "@/data/home";
export default function FeaturesBar() {
const ref = useRef<HTMLUListElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className="mx-auto w-full max-w-[1200px] px-4 py-12 sm:px-6 lg:py-16">
<ul ref={ref} className="grid grid-cols-2 gap-8 lg:grid-cols-4 lg:gap-0">
{FEATURES.map((feature, index) => (
<li
key={feature.id}
className={`seq-fade-item flex flex-col items-center px-4 text-center ${
index < FEATURES.length - 1
? "lg:border-s lg:border-ink/15"
: ""
}`}
style={{
transitionDelay: visible ? `${index * 110}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,24px,0)",
}}
>
<Image
src={feature.icon}
alt=""
width={44}
height={34}
unoptimized
className="mb-3 h-9 w-auto"
/>
<p className="font-doran text-lg font-medium tracking-tight text-ink sm:text-[22px]">
{feature.title}
</p>
<p className="mt-1 text-sm font-light text-ink/70 sm:text-[15px]">
{feature.subtitle}
</p>
</li>
))}
</ul>
</section>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { categoryPath } from "@/lib/category-path";
import { productPath } from "@/lib/product-path";
import { API_URL, STORE_SLUG } from "@/lib/public-api";
type SearchHit = {
id: number | string;
nameFa: string;
nameEn: string;
imageUrl?: string | null;
price?: number;
};
type CategoryHit = {
id: number;
nameFa: string;
nameEn: string;
};
export default function HeaderSearch() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<SearchHit[]>([]);
const [categories, setCategories] = useState<CategoryHit[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
if (!open) return;
const query = q.trim();
if (query.length < 2) {
setProducts([]);
setCategories([]);
return;
}
const t = window.setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
storeSlug: STORE_SLUG,
q: query,
limit: "8",
});
const res = await fetch(`${API_URL}/public/search?${params}`);
if (!res.ok) throw new Error("search failed");
const data = (await res.json()) as {
products?: SearchHit[];
categories?: CategoryHit[];
};
setProducts(data.products ?? []);
setCategories(data.categories ?? []);
} catch {
setProducts([]);
setCategories([]);
} finally {
setLoading(false);
}
}, 280);
return () => window.clearTimeout(t);
}, [q, open]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const query = q.trim();
if (!query) return;
setOpen(false);
window.location.href = `/products?q=${encodeURIComponent(query)}`;
}
return (
<>
<button
type="button"
aria-label="جستجو"
aria-expanded={open}
className="transition-opacity hover:opacity-70"
onClick={() => setOpen(true)}
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
{open ? (
<div className="fixed inset-0 z-[70]">
<button
type="button"
className="absolute inset-0 bg-ink/40"
aria-label="بستن جستجو"
onClick={() => setOpen(false)}
/>
<div
ref={panelRef}
className="absolute inset-x-0 top-0 bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]"
>
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-[900px] items-center gap-3 px-4 py-4 sm:px-6"
dir="rtl"
>
<Image
src="/images/home/icons/search.svg"
alt=""
width={18}
height={18}
unoptimized
/>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی محصول یا دسته‌بندی..."
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
/>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-muted hover:text-ink"
>
بستن
</button>
</form>
<div className="mx-auto max-w-[900px] border-t border-line px-4 py-4 sm:px-6">
{loading ? (
<p className="text-sm text-muted">در حال جستجو...</p>
) : q.trim().length < 2 ? (
<p className="text-sm text-muted">
حداقل ۲ حرف وارد کنید.
</p>
) : !products.length && !categories.length ? (
<p className="text-sm text-muted">نتیجه‌ای یافت نشد.</p>
) : (
<div className="grid gap-6 sm:grid-cols-2">
{categories.length ? (
<div>
<p className="mb-2 text-xs text-muted">دسته‌بندی‌ها</p>
<ul className="space-y-1">
{categories.map((c) => (
<li key={c.id}>
<Link
href={categoryPath(c)}
onClick={() => setOpen(false)}
className="block border border-transparent px-2 py-2 text-sm hover:border-line hover:bg-[#f7f7f5]"
>
{c.nameFa}
{c.nameEn ? (
<span
dir="ltr"
className="ms-2 font-montserrat text-[10px] text-muted"
>
{c.nameEn}
</span>
) : null}
</Link>
</li>
))}
</ul>
</div>
) : null}
{products.length ? (
<div>
<p className="mb-2 text-xs text-muted">محصولات</p>
<ul className="space-y-1">
{products.map((p) => (
<li key={p.id}>
<Link
href={productPath(p)}
onClick={() => setOpen(false)}
className="flex items-center gap-3 border border-transparent px-2 py-2 hover:border-line hover:bg-[#f7f7f5]"
>
<span className="relative h-12 w-10 shrink-0 overflow-hidden bg-[#f3f3f1]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
p.imageUrl ||
"/images/home/products/coat-blue.jpg"
}
alt=""
className="h-full w-full object-cover"
/>
</span>
<span className="min-w-0">
<span className="block truncate text-sm text-ink">
{p.nameFa}
</span>
<span
dir="ltr"
className="block truncate font-montserrat text-[10px] text-muted"
>
{p.nameEn}
</span>
</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { STORE_URL } from "@/lib/public-api";
/**
* Website (3000) and dashboards (3001) don't share localStorage on localhost.
* We still point at the store app: login when unknown, account/dashboard when a
* same-origin token exists (production shared domain), else login entry.
*/
export default function HeaderUserLink() {
const [href, setHref] = useState(`${STORE_URL}/login`);
const [label, setLabel] = useState("ورود");
useEffect(() => {
try {
const token = localStorage.getItem("mana_access_token");
const raw = localStorage.getItem("mana_user");
if (!token) {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
return;
}
let role = "";
if (raw) {
const user = JSON.parse(raw) as { role?: string };
role = user.role ?? "";
}
if (role === "STORE_ADMIN" || role === "SUPER_ADMIN") {
setHref(`${STORE_URL}/`);
setLabel("داشبورد");
} else {
setHref(`${STORE_URL}/account`);
setLabel("حساب کاربری");
}
} catch {
setHref(`${STORE_URL}/login`);
setLabel("ورود");
}
}, []);
return (
<Link
href={href}
aria-label={label}
title={label}
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/user.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import HoverFillButton from "./HoverFillButton";
export type HeroSlideData = {
id: string;
image: string;
imagePosition?: string;
title: string;
subtitle: string;
cta: { label: string; href: string };
};
const FALLBACK_SLIDES: HeroSlideData[] = [
{
id: "autumn",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_20%]",
title: "برای بانویی که خودش انتخاب میکند.",
subtitle: "made for her",
cta: { label: "کالکشن پاییزه", href: "/products?collection=autumn" },
},
{
id: "new",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_40%]",
title: "جدیدترین‌های فصل همین حالا ببینید.",
subtitle: "new arrivals",
cta: { label: "جدیدترین‌ها", href: "/products?sort=newest" },
},
{
id: "sale",
image: "/images/home/banners/hero.jpg",
imagePosition: "object-[center_55%]",
title: "فروش ویژه تا ۷۰٪ تخفیف",
subtitle: "up to 70% off",
cta: { label: "مشاهده تخفیف‌ها", href: "/products?sale=1" },
},
];
const AUTO_MS = 10000;
type HeroSliderProps = {
slides?: HeroSlideData[];
};
export default function HeroSlider({ slides }: HeroSliderProps) {
const list = useMemo(
() => (slides?.length ? slides : FALLBACK_SLIDES),
[slides],
);
const [index, setIndex] = useState(0);
const [textKey, setTextKey] = useState(0);
const [paused, setPaused] = useState(false);
const slide = list[index] ?? list[0];
useEffect(() => {
setIndex(0);
}, [list]);
const goTo = useCallback((next: number) => {
setIndex(next);
setTextKey((k) => k + 1);
}, []);
const goPrev = () => goTo((index - 1 + list.length) % list.length);
const goNext = () => goTo((index + 1) % list.length);
useEffect(() => {
if (paused || list.length <= 1) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const t = window.setInterval(() => {
setIndex((i) => (i + 1) % list.length);
setTextKey((k) => k + 1);
}, AUTO_MS);
return () => window.clearInterval(t);
}, [index, paused, list.length]);
if (!slide) return null;
return (
<section
className="relative h-[min(100dvh,1081px)] min-h-[560px] w-full overflow-hidden bg-[#ebe6df]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{list.map((s, i) => {
const active = i === index;
return (
<div
key={s.id}
className={`absolute inset-0 transition-opacity duration-1000 ease-out ${
active ? "opacity-100" : "opacity-0"
}`}
aria-hidden={!active}
>
<Image
key={active ? `on-${s.id}-${index}` : `off-${s.id}`}
src={s.image}
alt=""
fill
priority={i === 0}
sizes="100vw"
className={`object-cover ${s.imagePosition ?? "object-center"} ${
active ? "hero-ken-burns" : ""
} ${active && paused ? "hero-ken-burns-paused" : ""}`}
/>
</div>
);
})}
<div className="absolute inset-0 bg-gradient-to-l from-white/25 via-transparent to-transparent" />
<div className="absolute inset-y-0 right-16 z-10 flex w-[min(360px,calc(100%-6.5rem))] items-center sm:right-24 lg:right-[200px]">
<div
key={textKey}
dir="rtl"
className="hero-copy-in w-full pt-16 text-right"
>
<h1 className="hero-copy-item whitespace-pre-line font-doran text-right text-[clamp(1.25rem,2.2vw,2.25rem)] font-medium leading-[1.4] text-ink">
{slide.title}
</h1>
{slide.subtitle ? (
<p
dir="ltr"
className="hero-copy-item hero-copy-item-2 mt-3 block w-full whitespace-pre-line text-right font-montserrat text-[clamp(0.875rem,1.4vw,1.35rem)] font-light uppercase tracking-[0.28em] text-[#6d6d6d]"
>
{slide.subtitle}
</p>
) : null}
{slide.cta.label ? (
<div className="hero-copy-item hero-copy-item-3">
<HoverFillButton href={slide.cta.href || "#"} className="mt-7 w-full">
{slide.cta.label}
</HoverFillButton>
</div>
) : null}
</div>
</div>
{list.length > 1 ? (
<>
<div className="pointer-events-none absolute inset-y-0 left-4 z-20 flex items-center sm:left-8 lg:left-[120px]">
<button
type="button"
aria-label="اسلاید قبلی"
onClick={goPrev}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 right-4 z-20 flex items-center sm:right-8 lg:right-[120px]">
<button
type="button"
aria-label="اسلاید بعدی"
onClick={goNext}
className="pointer-events-auto flex size-10 items-center justify-center rounded-[4px] bg-white/20 backdrop-blur-sm transition hover:bg-white/40 sm:size-12"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
<div
dir="ltr"
className="absolute inset-x-0 bottom-8 z-20 flex items-center justify-center gap-2.5 sm:bottom-12"
role="tablist"
aria-label="اسلایدهای هیرو"
>
{list.map((s, i) => {
const active = i === index;
return (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`اسلاید ${i + 1}`}
onClick={() => goTo(i)}
className={`h-2 rounded-full transition-all duration-300 ${
active
? "w-8 bg-ink"
: "w-2 bg-ink/30 hover:bg-ink/50"
}`}
/>
);
})}
</div>
</>
) : null}
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
type HoverFillButtonProps = {
href: string;
children: ReactNode;
className?: string;
/** `light` = ink border on pale surfaces; `dark` = white border on ink surfaces */
variant?: "light" | "dark";
};
export default function HoverFillButton({
href,
children,
className = "",
variant = "light",
}: HoverFillButtonProps) {
const isDark = variant === "dark";
return (
<Link
href={href}
dir="rtl"
className={`group relative inline-flex h-[52px] items-center justify-between gap-3 overflow-hidden rounded-[5px] border px-4 backdrop-blur-sm sm:h-[56px] ${
isDark
? "border-white bg-transparent text-white"
: "border-ink bg-white/30 text-ink"
} ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute inset-0 origin-bottom scale-y-0 transition-transform duration-[400ms] ease-out group-hover:scale-y-100 ${
isDark ? "bg-white" : "bg-ink"
}`}
/>
<span
className={`relative z-10 text-base font-light transition-colors duration-[400ms] ease-out sm:text-xl ${
isDark ? "group-hover:text-ink" : "group-hover:text-white"
}`}
>
{children}
</span>
<Image
src={
isDark
? "/images/home/icons/caret-left.svg"
: "/images/home/icons/caret-left-dark.svg"
}
alt=""
width={28}
height={28}
unoptimized
className="relative z-10 transition-[filter] duration-[400ms] ease-out group-hover:invert"
/>
</Link>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Image from "next/image";
import HoverFillButton from "./HoverFillButton";
type LeatherBannerProps = {
image?: string;
nameFa?: string;
nameEn?: string;
linkText?: string;
link?: string;
};
export default function LeatherBanner({
image = "/images/home/banners/leather.png",
nameFa = "هنر پوشیدن",
nameEn = "THE ART OF DRESSING",
linkText = "کالکشن چرم",
link = "/products?collection=leather",
}: LeatherBannerProps) {
const enLines = nameEn.trim().split(/\n+/).filter(Boolean);
const line1 = enLines[0] ?? "THE ART";
const line2 = enLines[1] ?? (enLines.length === 1 ? "" : "OF DRESSING");
// If single-line English like "THE ART OF DRESSING", split roughly in half
const [display1, display2] =
enLines.length === 1 && line1.includes(" ")
? (() => {
const parts = line1.split(/\s+/);
const mid = Math.ceil(parts.length / 2);
return [parts.slice(0, mid).join(" "), parts.slice(mid).join(" ")];
})()
: [line1, line2 || "OF DRESSING"];
return (
<section className="relative w-full overflow-hidden bg-white">
<div className="relative mx-auto w-full max-w-[1920px] aspect-[1920/585]">
<Image
src={image}
alt={nameFa}
fill
sizes="100vw"
className="object-cover object-left-bottom"
/>
<div className="absolute inset-y-0 right-4 z-10 flex items-center sm:right-10 lg:right-[max(2rem,calc((100%-1709px)/2+186px))]">
<div
dir="ltr"
className="flex w-max max-w-[min(100%,42vw)] flex-col items-stretch text-left text-ink"
>
<p className="whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display1}
</p>
{display2 ? (
<p className="mt-1 whitespace-nowrap font-doran text-[clamp(1.5rem,4vw,4.5rem)] font-extrabold leading-none">
{display2}
</p>
) : null}
<p
dir="rtl"
className="mt-3 whitespace-nowrap font-doran text-[clamp(1.25rem,3.2vw,3.5rem)] font-light leading-none"
>
{nameFa}
</p>
{linkText ? (
<HoverFillButton
href={link || "#"}
className="mt-4 w-full !h-11 sm:!h-12"
>
{linkText}
</HoverFillButton>
) : null}
</div>
</div>
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import HoverFillButton from "./HoverFillButton";
export default function PartnerCta() {
return (
<section dir="rtl" className="bg-ink text-white">
<div className="mx-auto flex max-w-[1920px] flex-col items-stretch justify-between gap-6 px-4 py-14 sm:px-6 lg:flex-row lg:items-center lg:px-[120px] lg:py-16">
<div className="text-right lg:max-w-[560px]">
<p className="font-doran text-base font-normal sm:text-lg">
مایل هستید ما محصول شما را برای شما بفروشیم؟
</p>
<p className="mt-1.5 text-sm font-light text-white/85 sm:text-base">
اگر تولید کننده هستید، ما مایل به همکاری با شما هستیم
</p>
</div>
<HoverFillButton
href="/partners"
variant="dark"
className="!h-10 w-fit max-w-none shrink-0 gap-2 self-start px-3 sm:!h-11 sm:self-center [&_span]:!text-sm sm:[&_span]:!text-base [&_img]:!h-4 [&_img]:!w-4"
>
شروع به همکاری
</HoverFillButton>
</div>
</section>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Image from "next/image";
import Link from "next/link";
import type { Product } from "@/data/home";
import { productPath } from "@/lib/product-path";
type ProductCardProps = {
product: Product;
className?: string;
};
export default function ProductCard({ product, className = "" }: ProductCardProps) {
const href = product.href ?? productPath(product);
const hoverSrc = product.imageHover;
return (
<article
dir="rtl"
className={`group relative flex h-[491px] w-full flex-col rounded-[5px] border border-[#8e8e8e]/30 bg-white transition-colors duration-300 ease-out hover:border-ink ${className}`}
>
<Link href={href} className="flex min-h-0 flex-1 flex-col">
{/* Clip media only — keep border on the outer box so it never gets cut off */}
<span className="relative block min-h-0 flex-1 overflow-hidden rounded-t-[4px] p-2.5">
<span className="relative block h-full w-full overflow-hidden rounded-[5px]">
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 324px"
className={`object-cover object-top transition-transform duration-500 ease-out ${
hoverSrc ? "group-hover:translate-x-full" : "group-hover:scale-[1.04]"
}`}
/>
{hoverSrc ? (
<Image
src={hoverSrc}
alt=""
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-top -translate-x-full transition-transform duration-500 ease-out group-hover:translate-x-0"
aria-hidden
/>
) : null}
</span>
</span>
<span className="flex shrink-0 flex-col gap-2 px-3.5 pb-3.5 pt-1">
<span className="w-full text-right">
<span className="line-clamp-1 block text-[15px] font-medium text-ink">
{product.nameFa}
</span>
<span
dir="ltr"
className="mt-0.5 line-clamp-1 block w-full text-right font-montserrat text-[9px] font-light uppercase tracking-[0.2em] text-[#afafaf]"
>
{product.nameEn}
</span>
</span>
<span
dir="ltr"
className="mt-1 flex items-baseline justify-start gap-1.5 border-t border-ink/15 pt-3 pb-0.5 transition-colors duration-300 ease-out group-hover:border-ink"
>
<span className="font-montserrat text-base font-normal leading-none text-ink sm:text-[17px]">
{product.price}
</span>
<span className="text-[9px] leading-none text-[#878787]">IRT</span>
</span>
</span>
</Link>
<button
type="button"
aria-label="افزودن به علاقه‌مندی"
className="absolute end-3 top-4 z-10 transition-opacity hover:opacity-70"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Image
src="/images/home/icons/bookmark.svg"
alt=""
width={24}
height={24}
unoptimized
/>
</button>
</article>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "./ProductCard";
import PromoCard, { type PromoSlotOverride } from "./PromoCard";
type ProductRowProps = {
products: Product[];
promo: "sale" | "discount" | "autumn";
className?: string;
/** Stagger fade-in for promo + product cards when scrolled into view */
stagger?: boolean;
promoSlot?: PromoSlotOverride | null;
};
const CARD_W =
"w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
export default function ProductRow({
products,
promo,
className = "",
stagger = false,
promoSlot,
}: ProductRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const offsetRef = useRef(0);
const maxRef = useRef(0);
const stepRef = useRef(288);
const [offset, setOffset] = useState(0);
const [maxOffset, setMaxOffset] = useState(0);
const [visible, setVisible] = useState(!stagger);
const dragRef = useRef<{
pointerId: number | null;
startX: number;
startOffset: number;
dragging: boolean;
}>({ pointerId: null, startX: 0, startOffset: 0, dragging: false });
const applyOffset = useCallback((value: number) => {
const next = Math.min(maxRef.current, Math.max(0, value));
offsetRef.current = next;
setOffset(next);
}, []);
const measure = useCallback(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const card = track.querySelector<HTMLElement>("[data-carousel-card]");
const styles = getComputedStyle(track);
const gap = parseFloat(styles.columnGap || styles.gap || "8") || 8;
const cardW = card?.offsetWidth ?? 280;
stepRef.current = cardW + gap;
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
setMaxOffset(maxRef.current);
applyOffset(offsetRef.current);
}, [applyOffset]);
useEffect(() => {
measure();
const viewport = viewportRef.current;
if (!viewport) return;
const ro = new ResizeObserver(() => measure());
ro.observe(viewport);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
const t = window.setTimeout(measure, 200);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products, promo]);
useEffect(() => {
if (!stagger) return;
const el = sectionRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, [stagger]);
const go = (direction: "prev" | "next") => {
const delta = direction === "next" ? stepRef.current : -stepRef.current;
applyOffset(offsetRef.current + delta);
};
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
// Never steal pointer from links/buttons — cards are full <a> links
if (target.closest("a, button")) return;
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startOffset: offsetRef.current,
dragging: false,
};
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const dx = drag.startX - e.clientX;
if (!drag.dragging) {
// Wait for a real drag before capturing — plain clicks still hit <a>
if (Math.abs(e.clientX - drag.startX) < 8) return;
drag.dragging = true;
viewport.setPointerCapture(e.pointerId);
if (trackRef.current) trackRef.current.style.transition = "none";
}
applyOffset(drag.startOffset + dx);
e.preventDefault();
};
const onPointerUp = (e: PointerEvent) => {
const drag = dragRef.current;
if (drag.pointerId !== e.pointerId) return;
const wasDragging = drag.dragging;
drag.pointerId = null;
drag.dragging = false;
if (trackRef.current) trackRef.current.style.transition = "";
if (wasDragging) {
try {
viewport.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
const suppress = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
viewport.removeEventListener("click", suppress, true);
};
viewport.addEventListener("click", suppress, true);
window.setTimeout(
() => viewport.removeEventListener("click", suppress, true),
0,
);
}
};
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove, { passive: false });
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("pointercancel", onPointerUp);
return () => {
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("pointercancel", onPointerUp);
};
}, [applyOffset]);
return (
<section
ref={sectionRef}
dir="rtl"
className={`relative w-full pl-4 sm:pl-6 lg:pl-[120px] pr-8 sm:pr-12 lg:pr-[180px] ${className}`}
>
<div className="flex items-stretch gap-2 sm:gap-2.5 lg:gap-3">
<div
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? "0ms" : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<PromoCard variant={promo} className="max-w-none" slot={promoSlot} />
</div>
<div
ref={viewportRef}
className="relative min-w-0 flex-1 overflow-hidden touch-pan-y"
>
<div
ref={trackRef}
className="flex cursor-grab items-stretch gap-2 transition-transform duration-300 ease-out will-change-transform active:cursor-grabbing sm:gap-2.5 lg:gap-3"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
>
{products.map((product, i) => (
<div
key={`${promo}-${product.id}`}
data-carousel-card
className={`${CARD_W} seq-fade-item`}
style={
stagger
? {
transitionDelay: visible ? `${(i + 1) * 100}ms` : "0ms",
opacity: visible ? 1 : 0,
transform: visible
? "translate3d(0,0,0)"
: "translate3d(0,28px,0)",
}
: undefined
}
>
<ProductCard product={product} className="max-w-none" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-y-0 right-1 z-10 flex items-center sm:right-2">
<button
type="button"
aria-label="محصول قبلی"
disabled={offset <= 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("prev");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
<div className="pointer-events-none absolute inset-y-0 left-1 z-10 flex items-center sm:left-2">
<button
type="button"
aria-label="محصول بعدی"
disabled={offset >= maxOffset - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
go("next");
}}
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
</div>
</div>
</div>
</section>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { MEGA_MENU_TABS } from "@/data/home";
export type MegaColumn = {
title: string;
href: string;
links: readonly { label: string; href: string }[];
};
export type MegaTabData = {
id: string;
nameFa: string;
nameEn: string;
href: string;
image: string;
/** Second-level categories as columns, each with third-level children */
columns?: readonly MegaColumn[];
/** Flat fallback when columns are not provided */
links?: readonly { label: string; href: string }[];
};
type ProductsMegaMenuProps = {
open: boolean;
onClose: () => void;
tabs?: MegaTabData[];
};
function columnsForTab(tab: MegaTabData): MegaColumn[] {
if (tab.columns?.length) return [...tab.columns];
if (tab.links?.length) {
return [
{
title: tab.nameFa,
href: tab.href,
links: tab.links,
},
];
}
return [];
}
export default function ProductsMegaMenu({
open,
onClose,
tabs,
}: ProductsMegaMenuProps) {
const list = (tabs?.length ? tabs : MEGA_MENU_TABS) as MegaTabData[];
const [activeId, setActiveId] = useState(list[0]?.id ?? "coat");
const active = list.find((t) => t.id === activeId) ?? list[0];
if (!open || !active) return null;
const columns = columnsForTab(active);
return (
<div
id="products-mega-menu"
role="region"
aria-label="منوی محصولات"
className="pointer-events-auto absolute inset-x-0 top-full z-40 border-t border-ink/5 bg-white shadow-[0_16px_40px_rgba(0,0,0,0.1)]"
onMouseLeave={onClose}
>
<div className="mx-auto max-w-[1709px] px-4 py-5 sm:px-6 lg:px-10 lg:py-6">
<div
role="tablist"
aria-label="دسته‌بندی محصولات"
className="flex flex-wrap items-end justify-start gap-x-1 gap-y-1 border-b border-ink/10"
>
{list.map((tab) => {
const selected = tab.id === active.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
id={`mega-tab-${tab.id}`}
aria-controls={`mega-panel-${tab.id}`}
onMouseEnter={() => setActiveId(tab.id)}
onFocus={() => setActiveId(tab.id)}
onClick={() => setActiveId(tab.id)}
className={`-mb-px border-b-2 px-3 py-2.5 text-center transition-colors sm:px-4 ${
selected
? "border-ink"
: "border-transparent hover:opacity-90"
}`}
>
<span
className={`block font-doran text-sm font-medium sm:text-[15px] ${
selected ? "text-ink" : "text-ink/80"
}`}
>
{tab.nameFa}
</span>
<span
dir="ltr"
className={`mt-0.5 block font-montserrat text-[9px] font-light uppercase tracking-[0.22em] sm:text-[10px] ${
selected ? "text-ink/70" : "text-ink/40"
}`}
>
{tab.nameEn}
</span>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`mega-panel-${active.id}`}
aria-labelledby={`mega-tab-${active.id}`}
className="mt-5 flex items-start justify-start gap-6 sm:gap-8 lg:gap-10"
>
<div className="min-w-0 flex-1 pt-1">
<div className="grid grid-cols-2 gap-x-6 gap-y-7 sm:grid-cols-3 sm:gap-x-8 lg:grid-cols-4 xl:grid-cols-5 xl:gap-x-10">
{columns.map((col) => (
<div key={col.href + col.title} className="min-w-0">
<Link
href={col.href}
onClick={onClose}
className="block text-sm font-medium text-ink transition-opacity hover:opacity-70 sm:text-[15px]"
>
{col.title}
</Link>
{col.links.length > 0 ? (
<ul className="mt-2.5 flex flex-col gap-1.5 text-[13px] font-light text-ink/70 sm:text-sm">
{col.links.map((link) => (
<li key={link.href + link.label}>
<Link
href={link.href}
onClick={onClose}
className="transition-colors hover:text-ink"
>
{link.label}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</div>
</div>
<div className="relative hidden h-[160px] w-[120px] shrink-0 overflow-hidden bg-[#f3f3f3] sm:block sm:h-[200px] sm:w-[150px] lg:h-[220px] lg:w-[160px]">
{list.map((tab) => (
<div
key={tab.id}
className={`absolute inset-0 transition-opacity duration-[400ms] ease-out ${
tab.id === active.id ? "opacity-100" : "opacity-0"
}`}
aria-hidden={tab.id !== active.id}
>
<Image
src={tab.image}
alt=""
fill
sizes="160px"
className="object-cover"
/>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import Image from "next/image";
import Link from "next/link";
type PromoVariant = "sale" | "discount" | "autumn";
export type PromoSlotOverride = {
image?: string;
nameFa?: string;
nameEn?: string;
subtext?: string;
link?: string;
};
type PromoCardProps = {
variant: PromoVariant;
href?: string;
className?: string;
slot?: PromoSlotOverride | null;
};
const META: Record<
PromoVariant,
{ href: string; labelFa: string }
> = {
sale: { href: "/products?sale=1", labelFa: "" },
discount: { href: "/products?sale=1", labelFa: "بهترین مانتوهای ما" },
autumn: {
href: "/products?collection=autumn",
labelFa: "کالکشن پاییزه",
},
};
const CARD_SHELL =
"relative flex h-[491px] w-full flex-col overflow-hidden rounded-[19px] border border-[#8e8e8e]/30";
/** Full-bleed creative when Website Images provides a slot image. */
function FullImagePromo({
href,
image,
alt,
className,
}: {
href: string;
image: string;
alt: string;
className: string;
}) {
return (
<Link href={href} className={`${CARD_SHELL} bg-white ${className}`}>
<Image
src={image}
alt={alt}
fill
sizes="(max-width: 768px) 50vw, 324px"
className="object-cover object-center"
/>
</Link>
);
}
export default function PromoCard({
variant,
href,
className = "",
slot,
}: PromoCardProps) {
const meta = META[variant];
const to = slot?.link || href || meta.href;
if (slot?.image) {
return (
<FullImagePromo
href={to}
image={slot.image}
alt={slot.nameFa || slot.nameEn || slot.subtext || "promo"}
className={className}
/>
);
}
if (variant === "sale") {
return (
<Link href={to} dir="ltr" className={`${CARD_SHELL} bg-ink text-white ${className}`}>
<div className="relative flex flex-1 items-center justify-center px-6 pt-10">
<div className="relative h-[200px] w-[140px]">
<Image
src="/images/home/banners/sale-script.png"
alt=""
fill
className="object-contain opacity-45"
sizes="140px"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/images/home/icons/sale-wordmark.svg"
alt="Sale"
width={200}
height={100}
unoptimized
className="h-auto w-[170px] max-w-none"
/>
</div>
</div>
</div>
<div className="px-6 pb-8 text-center">
<p className="whitespace-nowrap font-montserrat text-[15px] font-normal tracking-[0.2em] uppercase sm:text-lg">
Our Season Finale
</p>
<p className="mt-1 whitespace-nowrap font-montserrat text-xl font-semibold tracking-[0.18em] uppercase sm:text-2xl">
Up to 70% OFF
</p>
</div>
</Link>
);
}
if (variant === "discount") {
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 text-ink sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 flex-col items-center justify-center text-center uppercase">
<p className="whitespace-nowrap text-[24px] font-light tracking-[0.7em] sm:text-[30px] sm:tracking-[0.84em]">
Up TO
</p>
<p className="-mt-1 font-montserrat text-[64px] font-medium leading-none tracking-[0.12em] sm:text-[80px] sm:tracking-[0.19em]">
70%
</p>
<p className="-mt-1 whitespace-nowrap font-montserrat text-[24px] font-light tracking-[0.1em] sm:text-[30px] sm:tracking-[0.13em]">
Discount
</p>
</div>
<p className="text-center text-[18px] font-light sm:text-[23px]">
{slot?.nameFa || meta.labelFa}
</p>
</Link>
);
}
return (
<Link
href={to}
dir="ltr"
className={`${CARD_SHELL} bg-white px-6 py-8 sm:px-8 sm:py-10 ${className}`}
>
<div className="flex flex-1 items-center justify-center">
<div className="relative size-[140px] sm:size-[160px]">
<Image
src="/images/home/banners/autumn-leaf.png"
alt=""
fill
className="object-contain mix-blend-darken"
sizes="160px"
/>
</div>
</div>
<div className="flex flex-col items-center text-center uppercase">
<p className="whitespace-nowrap font-montserrat text-[18px] font-light tracking-[0.04em]">
i wanted it for
</p>
<p className="font-montserrat text-[32px] font-medium tracking-[0.2em]">
Autumn
</p>
<p className="mt-3 text-[18px] font-light normal-case sm:text-[20px]">
{slot?.nameFa || meta.labelFa}
</p>
</div>
</Link>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
/** Stagger delay in ms once visible */
delay?: number;
/** Optional custom y offset in px */
offset?: number;
};
export default function Reveal({
children,
className = "",
delay = 0,
offset = 36,
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
},
{ threshold: 0.12, rootMargin: "0px 0px -6% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translate3d(0,0,0)" : `translate3d(0,${offset}px,0)`,
filter: visible ? "blur(0)" : "blur(8px)",
transition: `opacity 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms, filter 0.9s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
willChange: "opacity, transform, filter",
}}
>
{children}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import Image from "next/image";
import Link from "next/link";
import {
FOOTER_CATEGORIES_COL1,
FOOTER_CATEGORIES_COL2,
FOOTER_STORE_LINKS,
SOCIAL_LINKS,
} from "@/data/home";
type SiteFooterProps = {
paymentBadges?: string;
};
export default function SiteFooter({
paymentBadges = "/images/home/banners/payment-badges.png",
}: SiteFooterProps) {
return (
<footer dir="rtl" className="mt-8">
<div className="bg-[#ebebeb]">
<div className="mx-auto flex max-w-[1920px] flex-col items-center justify-between gap-4 px-4 py-5 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="max-w-xl text-center text-sm font-light text-ink sm:text-start sm:text-base">
برای اطلاع از جدیدترین محصولات و تخفیفات، ما را در شبکه‌های اجتماعی
دنبال کنید.
</p>
<ul className="flex items-center gap-5 sm:gap-6">
{SOCIAL_LINKS.map((social) => (
<li key={social.id}>
<a
href={social.href}
aria-label={social.label}
className="transition-opacity hover:opacity-70"
>
<Image
src={social.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</a>
</li>
))}
</ul>
</div>
</div>
<div id="contact" className="scroll-mt-24 bg-[#242424] text-white">
<div className="mx-auto grid max-w-[1920px] gap-8 px-4 py-10 sm:px-6 lg:grid-cols-4 lg:gap-8 lg:px-[120px] lg:py-12">
{/* Start (right in RTL): contact */}
<div className="text-start">
<h3 className="mb-4 text-base font-medium sm:text-lg">
اطلاعات تماس:
</h3>
<div className="space-y-2 text-sm font-light leading-relaxed sm:text-[15px]">
<p>
<span className="text-[#a6a6a6]">آدرس: </span>
تهران، خیابان ولیعصر بالاتر از پارک ساعی، پلاک ۲۴۸
</p>
<p>
<span className="text-[#a6a6a6]">تلفن فروشگاه: </span>
<span dir="ltr">021-22423600</span>
</p>
<p>
<span className="text-[#a6a6a6]">تلفن همراه: </span>
<span dir="ltr">09122504800</span>
</p>
<p>
<span className="text-[#a6a6a6]">آدرس ایمیل: </span>
<span dir="ltr">Info@ManaCollecitions.com</span>
</p>
</div>
</div>
{/* Store */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">فروشگاه</h3>
<ul className="space-y-2 text-sm font-light sm:text-[15px]">
{FOOTER_STORE_LINKS.map((link) => (
<li key={link.label}>
<Link href={link.href} className="hover:opacity-70">
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Categories */}
<div className="text-start">
<h3 className="mb-4 text-base font-bold sm:text-lg">
دسته بندی محصولات
</h3>
<div className="grid grid-cols-2 gap-x-6 text-sm font-light text-[#e1e1e1] sm:text-[15px]">
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL1.map((label) => (
<li key={label}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
<ul className="space-y-2">
{FOOTER_CATEGORIES_COL2.map((label) => (
<li key={`c2-${label}`}>
<Link
href="/categories"
className="hover:opacity-70"
>
{label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* End (left in RTL): map */}
<div className="min-h-[180px] overflow-hidden rounded-[4px] bg-[#1a1a1a] sm:min-h-[200px]">
<iframe
title="موقعیت فروشگاه مانا کالکشن"
src="https://maps.google.com/maps?q=Tehran%20Valiasr%20Street%20above%20Saei%20Park%20No%20248&z=16&output=embed"
className="h-full min-h-[180px] w-full border-0 [filter:invert(92%)_hue-rotate(180deg)_brightness(0.9)_contrast(0.95)] sm:min-h-[200px]"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
</div>
</div>
<div className="bg-black text-white">
<div className="mx-auto flex max-w-[1920px] flex-col-reverse items-center justify-between gap-4 px-4 py-4 sm:flex-row sm:px-6 lg:px-[120px]">
<p className="text-center text-xs font-light sm:text-start sm:text-sm">
تمامی حق و حقوق مادی معنوی وبسایت متعلق به شرکت مانا کالکشن می‌باشد.
۱۴۰۴-۱۴۰۵
</p>
<div className="relative h-8 w-[160px] sm:h-9 sm:w-[190px]">
<Image
src={paymentBadges}
alt="روش‌های پرداخت"
fill
className="object-contain object-end"
sizes="190px"
/>
</div>
</div>
</div>
</footer>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { NAV_LINKS } from "@/data/home";
import HeaderSearch from "./HeaderSearch";
import HeaderUserLink from "./HeaderUserLink";
import ProductsMegaMenu, { type MegaTabData } from "./ProductsMegaMenu";
type SiteHeaderProps = {
megaTabs?: MegaTabData[];
};
export default function SiteHeader({ megaTabs }: SiteHeaderProps) {
const [scrolled, setScrolled] = useState(false);
const [megaOpen, setMegaOpen] = useState(false);
const closeTimer = useRef<number | null>(null);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 24);
setMegaOpen(false);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!megaOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMegaOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [megaOpen]);
const openMega = () => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
setMegaOpen(true);
};
const scheduleClose = () => {
if (closeTimer.current) window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setMegaOpen(false), 120);
};
return (
<header
className={`pointer-events-none z-50 transition-[padding,background-color] duration-300 ease-out ${
scrolled
? "fixed inset-x-0 top-0 bg-white px-0 pt-0 shadow-[0_1px_0_rgba(0,0,0,0.06)]"
: "absolute inset-x-0 top-0 px-0 pt-6 sm:pt-8 lg:pt-14"
}`}
>
<div
className={`relative transition-[padding] duration-300 ease-out ${
scrolled ? "px-0" : "px-4 sm:px-6 lg:px-[120px]"
}`}
>
<div
className={`pointer-events-auto mx-auto flex items-center justify-between gap-3 transition-[height,border-radius,background-color,backdrop-filter,border-color,max-width,padding] duration-300 ease-out ${
scrolled
? "h-[56px] w-full max-w-none rounded-none border-0 bg-white px-4 sm:h-[64px] sm:px-6 lg:px-10"
: "h-[60px] max-w-[1709px] rounded-[5px] border border-white/20 bg-white/20 px-4 backdrop-blur-[25px] sm:h-[80px] sm:px-6 lg:px-8"
}`}
>
<Link
href="/"
aria-label="Mana Collection"
className={`flex shrink-0 items-center justify-center rounded-[2px] border border-ink transition-[width,height] duration-300 ${
scrolled ? "size-9 sm:size-10" : "size-11 sm:size-[52px]"
}`}
>
<span
className={`font-montserrat font-light leading-none transition-[font-size] duration-300 ${
scrolled
? "text-[20px] sm:text-[24px]"
: "text-[26px] sm:text-[32px]"
}`}
>
M
</span>
</Link>
<nav className="hidden items-center gap-5 lg:flex xl:gap-6">
{NAV_LINKS.map((link) => {
const isProducts = "hasDropdown" in link && link.hasDropdown;
if (isProducts) {
return (
<div
key={link.label}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
>
<button
type="button"
aria-expanded={megaOpen}
aria-haspopup="true"
aria-controls="products-mega-menu"
onClick={() => setMegaOpen((v) => !v)}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
<Image
src="/images/home/icons/caret-down.svg"
alt=""
width={16}
height={16}
unoptimized
className={`transition-transform duration-300 ${
megaOpen ? "rotate-180" : ""
}`}
/>
</button>
</div>
);
}
return (
<Link
key={link.label}
href={link.href}
className="inline-flex items-center gap-1 text-[14px] font-light text-black transition-opacity hover:opacity-70 sm:text-[15px]"
>
{link.label}
</Link>
);
})}
</nav>
<div dir="ltr" className="flex items-center gap-3.5 sm:gap-4">
<HeaderUserLink />
<Link
href="/cart"
aria-label="سبد خرید"
className="transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/bag.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</Link>
<HeaderSearch />
</div>
</div>
<div
onMouseEnter={openMega}
onMouseLeave={scheduleClose}
className={scrolled ? "" : "px-0"}
>
<ProductsMegaMenu
open={megaOpen}
onClose={() => setMegaOpen(false)}
tabs={megaTabs}
/>
</div>
</div>
</header>
);
}
@@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
type ImageLightboxProps = {
images: string[];
index: number;
alt: string;
open: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
};
export default function ImageLightbox({
images,
index,
alt,
open,
onClose,
onIndexChange,
}: ImageLightboxProps) {
const count = images.length;
const go = useCallback(
(dir: -1 | 1) => {
if (!count) return;
onIndexChange((index + dir + count) % count);
},
[count, index, onIndexChange],
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") go(1);
if (e.key === "ArrowRight") go(-1);
};
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose, go]);
if (!open || !images[index]) return null;
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
>
<button
type="button"
className="absolute inset-0 cursor-zoom-out"
aria-label="بستن"
onClick={onClose}
/>
<div className="relative z-10 flex w-full max-w-5xl flex-col gap-3">
<div className="flex items-center justify-between gap-3 text-white">
<p dir="ltr" className="font-montserrat text-xs tracking-wide">
{index + 1} / {count}
</p>
<button
type="button"
onClick={onClose}
className="flex h-11 w-11 items-center justify-center rounded-[5px] border border-white/40 text-2xl leading-none transition-colors hover:bg-white hover:text-ink"
aria-label="بستن"
>
×
</button>
</div>
<div className="relative aspect-[3/4] w-full overflow-hidden sm:aspect-[4/5] lg:aspect-[16/11]">
<Image
src={images[index]}
alt={alt}
fill
priority
sizes="(max-width: 1024px) 100vw, 960px"
className="object-contain"
/>
</div>
{count > 1 ? (
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => go(-1)}
className="rounded-[5px] border border-white/40 px-4 py-2 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
بعدی
</button>
<div className="flex gap-2">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
type="button"
onClick={() => onIndexChange(i)}
className={`relative h-14 w-11 overflow-hidden rounded-[4px] border ${
i === index ? "border-white" : "border-white/30 opacity-70"
}`}
aria-label={`تصویر ${i + 1}`}
>
<Image src={src} alt="" fill className="object-cover" sizes="44px" />
</button>
))}
</div>
<button
type="button"
onClick={() => go(1)}
className="rounded-[5px] border border-white/40 px-4 py-2 text-sm text-white transition-colors hover:bg-white hover:text-ink"
>
قبلی
</button>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import Image from "next/image";
import { useMemo, useState } from "react";
import type { ProductDetail, ProductVariantOption } from "@/data/product-detail";
type ProductBuyBoxProps = {
product: ProductDetail;
};
function OptionRow({
labelFa,
labelEn,
options,
value,
onChange,
kind,
}: {
labelFa: string;
labelEn: string;
options: ProductVariantOption[];
value: string;
onChange: (id: string) => void;
kind: "color" | "size" | "package";
}) {
if (!options.length) return null;
return (
<div className="space-y-2.5">
<div className="flex items-baseline justify-between gap-3">
<p className="text-sm text-ink">{labelFa}</p>
<p dir="ltr" className="font-montserrat text-[10px] tracking-[0.14em] text-muted uppercase">
{labelEn}
</p>
</div>
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const selected = value === opt.id;
const disabled = opt.available === false;
const tip = opt.labelEn || opt.labelFa;
if (kind === "color") {
return (
<button
key={opt.id}
type="button"
disabled={disabled}
title={tip}
aria-label={tip}
onClick={() => onChange(opt.id)}
className={`group/swatch relative h-9 w-9 rounded-full border transition-transform ${
selected ? "border-ink scale-105" : "border-line"
} ${disabled ? "opacity-35 cursor-not-allowed" : "hover:border-ink"}`}
aria-pressed={selected}
>
<span
className="absolute inset-1 rounded-full border border-black/5"
style={{ background: opt.swatch || "#ccc" }}
/>
<span
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-[3px] bg-ink px-2 py-1 font-montserrat text-[10px] uppercase tracking-[0.08em] text-white opacity-0 transition-opacity group-hover/swatch:opacity-100"
>
{tip}
</span>
</button>
);
}
return (
<button
key={opt.id}
type="button"
disabled={disabled}
title={tip}
aria-label={tip}
onClick={() => onChange(opt.id)}
className={`min-w-[2.75rem] border px-3 py-2 text-sm uppercase tracking-[0.04em] transition-colors ${
selected
? "border-ink bg-ink text-white"
: "border-line bg-white text-ink hover:border-ink"
} ${disabled ? "opacity-35 cursor-not-allowed line-through" : ""}`}
aria-pressed={selected}
>
{kind === "size" ? opt.labelEn.toUpperCase() : opt.labelFa}
</button>
);
})}
</div>
</div>
);
}
export default function ProductBuyBox({ product }: ProductBuyBoxProps) {
const firstColor =
product.colors.find((c) => c.available !== false)?.id ??
product.colors[0]?.id ??
"";
const firstSize =
product.sizes.find((s) => s.available !== false)?.id ??
product.sizes[0]?.id ??
"";
const firstPackage =
product.packages.find((p) => p.available !== false)?.id ??
product.packages[0]?.id ??
"";
const [color, setColor] = useState(firstColor);
const [size, setSize] = useState(firstSize);
const [pkg, setPkg] = useState(firstPackage);
const selectedColor = useMemo(
() => product.colors.find((c) => c.id === color),
[color, product.colors],
);
return (
<div className="flex flex-col gap-6 lg:gap-8">
<div className="text-right">
<h2 className="font-doran text-[clamp(1.5rem,2.5vw,2rem)] font-medium text-ink">
{product.nameFa}
</h2>
<p
dir="ltr"
className="mt-2 text-right font-montserrat text-xs font-light uppercase tracking-[0.18em] text-muted"
>
{product.nameEn}
</p>
<p className="mt-4 text-sm leading-7 text-ink/75">{product.summary}</p>
</div>
<div className="space-y-5">
<OptionRow
labelFa={`رنگ${selectedColor ? ` · ${selectedColor.labelFa}` : ""}`}
labelEn="Color"
options={product.colors}
value={color}
onChange={setColor}
kind="color"
/>
<OptionRow
labelFa="سایز"
labelEn="Size"
options={product.sizes}
value={size}
onChange={setSize}
kind="size"
/>
<OptionRow
labelFa="بسته‌بندی"
labelEn="Package"
options={product.packages}
value={pkg}
onChange={setPkg}
kind="package"
/>
</div>
<div dir="ltr" className="flex items-baseline gap-2 border-y border-line py-4">
<p className="font-montserrat text-2xl text-ink sm:text-3xl">
{product.price}
</p>
<p className="text-xs text-muted">IRT</p>
</div>
{/* Sizing guide frame */}
<div className="relative overflow-hidden rounded-[5px] border border-dashed border-ink/25 bg-[#f7f7f5]">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-60"
style={{
backgroundImage:
"radial-gradient(circle, rgba(10,10,10,0.12) 0.7px, transparent 1px)",
backgroundSize: "12px 12px",
}}
/>
<div className="relative grid gap-4 p-4 sm:grid-cols-[minmax(0,1fr)_140px] sm:items-center">
<div>
<p className="font-doran text-lg text-ink">راهنمای سایز</p>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Sizing guide
</p>
<p className="mt-2 text-xs leading-6 text-ink/70">
{product.sizeGuideNote}
</p>
<a
href="#product-tabs"
className="mt-3 inline-flex text-sm text-ink underline underline-offset-4 hover:opacity-70"
>
مشاهده جدول اندازه‌ها
</a>
</div>
<div className="relative mx-auto aspect-[3/4] w-full max-w-[140px] overflow-hidden rounded-[5px] border border-line bg-white">
{product.sizeGuideImage ? (
<Image
src={product.sizeGuideImage}
alt=""
fill
sizes="140px"
className="object-cover object-top opacity-90"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[11px] text-muted">
Size chart
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
className="group relative inline-flex h-12 flex-1 items-center justify-center overflow-hidden rounded-[5px] border border-ink bg-ink px-4 text-white"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-white transition-transform duration-[400ms] ease-out group-hover:scale-y-100"
/>
<span className="relative z-10 text-sm transition-colors duration-[400ms] group-hover:text-ink sm:text-base">
افزودن به سبد
</span>
</button>
<button
type="button"
className="inline-flex h-12 items-center justify-center gap-2 rounded-[5px] border border-line px-4 text-sm text-ink transition-colors hover:border-ink"
aria-label="افزودن به علاقه‌مندی"
>
<Image
src="/images/home/icons/bookmark.svg"
alt=""
width={20}
height={20}
unoptimized
/>
علاقه‌مندی
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import Image from "next/image";
import Link from "next/link";
import { LIST_PAGE_META } from "@/data/products";
type Crumb = { label: string; href?: string };
type ProductDetailHeroProps = {
heroImage?: string;
breadcrumbs: readonly Crumb[];
};
/** Shorter list-style title band — home hero image, breadcrumbs only. */
export default function ProductDetailHero({
heroImage = LIST_PAGE_META.heroImage,
breadcrumbs,
}: ProductDetailHeroProps) {
return (
<section className="relative h-[min(28vw,220px)] min-h-[140px] w-full overflow-hidden bg-[#ebe6df] sm:min-h-[160px]">
<Image
src={heroImage}
alt=""
fill
priority
sizes="100vw"
className="object-cover object-[center_20%]"
/>
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
<div className="absolute inset-x-0 bottom-4 z-10 px-4 sm:bottom-5 sm:px-6 lg:bottom-6 lg:px-[120px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex flex-wrap items-center gap-1.5 text-[11px] font-light text-ink sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span
key={`${crumb.label}-${i}`}
className="inline-flex items-center gap-1.5 sm:gap-2"
>
{i > 0 ? (
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={12}
height={12}
unoptimized
/>
) : null}
{crumb.href ? (
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
</div>
</section>
);
}
@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import ImageLightbox from "./ImageLightbox";
type ProductGalleryProps = {
images: string[];
alt: string;
};
export default function ProductGallery({ images, alt }: ProductGalleryProps) {
const gallery = images.length ? images : ["/images/home/products/coat-blue.jpg"];
const [active, setActive] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="group relative aspect-[3/4] w-full overflow-hidden rounded-[5px] border border-line bg-[#f6f6f4] text-start"
aria-label="باز کردن لایت‌باکس"
>
<Image
src={gallery[active]}
alt={alt}
fill
priority
sizes="(max-width: 1024px) 100vw, 560px"
className="object-cover object-top transition-transform duration-500 ease-out group-hover:scale-[1.02]"
/>
<span className="absolute bottom-3 start-3 rounded-[4px] border border-white/50 bg-ink/50 px-2.5 py-1 text-[11px] text-white backdrop-blur-sm">
بزرگ‌نمایی
</span>
</button>
{gallery.length > 1 ? (
<ul className="grid grid-cols-4 gap-2 sm:gap-3">
{gallery.map((src, i) => (
<li key={`${src}-${i}`}>
<button
type="button"
onClick={() => setActive(i)}
onDoubleClick={() => {
setActive(i);
setLightboxOpen(true);
}}
className={`relative aspect-[3/4] w-full overflow-hidden rounded-[5px] border transition-colors ${
i === active
? "border-ink"
: "border-line hover:border-ink/50"
}`}
aria-label={`پیش‌نمایش ${i + 1}`}
aria-current={i === active}
>
<Image
src={src}
alt=""
fill
sizes="120px"
className="object-cover object-top"
/>
</button>
</li>
))}
</ul>
) : null}
<ImageLightbox
images={gallery}
index={active}
alt={alt}
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
onIndexChange={setActive}
/>
</div>
);
}
@@ -0,0 +1,194 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import type { ProductComment, ProductDetail } from "@/data/product-detail";
type TabId = "technical" | "comments" | "sizing";
const TABS: Array<{ id: TabId; labelFa: string; labelEn: string }> = [
{ id: "technical", labelFa: "مشخصات فنی", labelEn: "Technical info" },
{ id: "comments", labelFa: "نظرات", labelEn: "Comments" },
{ id: "sizing", labelFa: "راهنمای سایز", labelEn: "Sizing guide" },
];
type ProductTabsProps = {
product: ProductDetail;
initialTab?: TabId;
};
function Stars({ rating }: { rating: number }) {
return (
<span dir="ltr" className="font-montserrat text-xs tracking-widest text-ink" aria-label={`${rating} از ۵`}>
{"★".repeat(Math.max(0, Math.min(5, rating)))}
<span className="text-muted">{"★".repeat(Math.max(0, 5 - rating))}</span>
</span>
);
}
function CommentsList({ comments }: { comments: ProductComment[] }) {
if (!comments.length) {
return (
<p className="text-sm text-muted">هنوز نظری ثبت نشده است.</p>
);
}
return (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="border border-line bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-ink">{c.author}</p>
<Stars rating={c.rating} />
</div>
<p dir="ltr" className="mt-1 font-montserrat text-[10px] text-muted">
{c.date}
</p>
<p className="mt-3 text-sm leading-7 text-ink/80">{c.body}</p>
</li>
))}
</ul>
);
}
export default function ProductTabs({
product,
initialTab = "technical",
}: ProductTabsProps) {
const [tab, setTab] = useState<TabId>(initialTab);
useEffect(() => {
const onHash = () => {
if (window.location.hash === "#product-tabs") {
setTab("sizing");
}
};
onHash();
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
return (
<section id="product-tabs" className="scroll-mt-28 border-t border-line pt-10">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`-mb-px border-b-2 px-3 py-3 text-start transition-colors sm:px-4 ${
active
? "border-ink text-ink"
: "border-transparent text-muted hover:text-ink"
}`}
aria-selected={active}
role="tab"
>
<span className="block text-sm sm:text-base">{t.labelFa}</span>
<span
dir="ltr"
className="mt-0.5 block font-montserrat text-[10px] tracking-[0.12em] uppercase opacity-70"
>
{t.labelEn}
</span>
</button>
);
})}
</div>
<div className="py-8" role="tabpanel">
{tab === "technical" ? (
<div className="space-y-6">
<p className="max-w-3xl text-sm leading-7 text-ink/75">
{product.description}
</p>
<dl className="divide-y divide-line border border-line">
{product.technical.map((row) => (
<div
key={row.labelEn}
className="grid grid-cols-1 gap-1 px-4 py-3 sm:grid-cols-[minmax(0,11rem)_1fr] sm:items-baseline sm:gap-6"
>
<dt>
<span className="text-sm text-ink">{row.labelFa}</span>
<span
dir="ltr"
className="mt-0.5 block font-montserrat text-[10px] tracking-wide text-muted"
>
{row.labelEn}
</span>
</dt>
<dd className="text-sm text-ink/80 sm:text-end">{row.value}</dd>
</div>
))}
</dl>
</div>
) : null}
{tab === "comments" ? (
<CommentsList comments={product.comments} />
) : null}
{tab === "sizing" ? (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:items-start">
<div className="relative aspect-[4/5] overflow-hidden rounded-[5px] border border-line bg-[#f6f6f4]">
{product.sizeGuideImage ? (
<Image
src={product.sizeGuideImage}
alt="راهنمای سایز"
fill
sizes="(max-width: 1024px) 100vw, 520px"
className="object-cover object-top"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-sm text-muted">
Size guide image
</span>
)}
</div>
<div>
<h3 className="font-doran text-xl text-ink">جدول اندازه‌ها</h3>
<p
dir="ltr"
className="mt-1 font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
>
Measurements (cm)
</p>
<p className="mt-3 text-sm leading-7 text-ink/75">
{product.sizeGuideNote}
</p>
<div className="mt-5 overflow-x-auto border border-line">
<table className="w-full min-w-[320px] text-sm">
<thead className="bg-[#f7f7f5] text-muted">
<tr>
<th className="px-3 py-2.5 text-start font-normal">سایز</th>
<th className="px-3 py-2.5 text-start font-normal">سینه</th>
<th className="px-3 py-2.5 text-start font-normal">کمر</th>
<th className="px-3 py-2.5 text-start font-normal">قد</th>
</tr>
</thead>
<tbody>
{[
["S", "۸۸", "۷۰", "۹۲"],
["M", "۹۲", "۷۴", "۹۵"],
["L", "۹۶", "۷۸", "۹۸"],
["XL", "۱۰۰", "۸۲", "۱۰۰"],
].map((row) => (
<tr key={row[0]} className="border-t border-line">
{row.map((cell) => (
<td key={cell} className="px-3 py-2.5 text-ink">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Product } from "@/data/home";
import ProductCard from "@/components/home/ProductCard";
type SimilarProductsProps = {
products: Product[];
};
export default function SimilarProducts({ products }: SimilarProductsProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const offsetRef = useRef(0);
const maxRef = useRef(0);
const stepRef = useRef(300);
const [offset, setOffset] = useState(0);
const [maxOffset, setMaxOffset] = useState(0);
const applyOffset = useCallback((value: number) => {
const next = Math.min(maxRef.current, Math.max(0, value));
offsetRef.current = next;
setOffset(next);
}, []);
const measure = useCallback(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) return;
const card = track.querySelector<HTMLElement>("[data-similar-card]");
const styles = getComputedStyle(track);
const gap = parseFloat(styles.columnGap || styles.gap || "12") || 12;
const cardW = card?.offsetWidth ?? 280;
stepRef.current = cardW + gap;
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
setMaxOffset(maxRef.current);
applyOffset(offsetRef.current);
}, [applyOffset]);
useEffect(() => {
measure();
const viewport = viewportRef.current;
if (!viewport) return;
const ro = new ResizeObserver(() => measure());
ro.observe(viewport);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
const t = window.setTimeout(measure, 200);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
window.clearTimeout(t);
};
}, [measure, products]);
if (!products.length) return null;
const canPrev = offset > 0;
const canNext = offset < maxOffset - 1;
return (
<section dir="rtl" className="border-t border-line pt-10">
<div className="mb-6 flex items-end justify-between gap-4">
<div className="min-w-0 text-right">
<h2 className="font-doran text-2xl font-medium text-ink sm:text-3xl">
محصولات مشابه
</h2>
<p
dir="ltr"
className="mt-1 w-full text-right font-montserrat text-[11px] font-light uppercase tracking-[0.18em] text-muted"
>
Similar products
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
aria-label="قبلی"
disabled={!canPrev}
onClick={() => applyOffset(offsetRef.current - stepRef.current)}
className="flex size-10 items-center justify-center border border-line bg-white transition-colors hover:border-ink disabled:pointer-events-none disabled:opacity-30"
>
<Image
src="/images/home/icons/caret-right.svg"
alt=""
width={18}
height={18}
unoptimized
/>
</button>
<button
type="button"
aria-label="بعدی"
disabled={!canNext}
onClick={() => applyOffset(offsetRef.current + stepRef.current)}
className="flex size-10 items-center justify-center border border-line bg-white transition-colors hover:border-ink disabled:pointer-events-none disabled:opacity-30"
>
<Image
src="/images/home/icons/caret-left-dark.svg"
alt=""
width={18}
height={18}
unoptimized
/>
</button>
</div>
</div>
<div ref={viewportRef} className="relative overflow-hidden">
<div
ref={trackRef}
className="flex gap-3 transition-transform duration-300 ease-out will-change-transform"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
>
{products.map((product) => (
<div
key={product.id}
data-similar-card
className="w-[240px] shrink-0 sm:w-[260px] lg:w-[280px]"
>
<ProductCard product={product} className="max-w-none" />
</div>
))}
</div>
</div>
</section>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import Image from "next/image";
import { useState } from "react";
type ActiveFilter = {
id: string;
label: string;
value: string;
};
const INITIAL_FILTERS: ActiveFilter[] = [
{ id: "cat", label: "دسته بندی", value: "شلوار جین" },
{ id: "min", label: "حداقل قیمت", value: "۳.۰۰۰.۰۰۰ تومان" },
];
function FilterField({
label,
value,
onChange,
placeholder,
iconSrc,
className = "",
inputMode,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
iconSrc: string;
className?: string;
inputMode?: "text" | "numeric";
}) {
return (
<label className={`relative block w-full ${className}`}>
<span className="sr-only">{label}</span>
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
<Image src={iconSrc} alt="" width={16} height={16} unoptimized className="opacity-70" />
</span>
<input
type="text"
inputMode={inputMode}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-9 w-full rounded-[5px] border border-[#818181]/40 bg-white pe-3 ps-9 text-right text-sm font-light text-ink placeholder:text-[#818181] outline-none focus:border-ink"
/>
</label>
);
}
export default function ProductFilters() {
const [name, setName] = useState("");
const [minPrice, setMinPrice] = useState("");
const [maxPrice, setMaxPrice] = useState("");
const [active, setActive] = useState<ActiveFilter[]>(INITIAL_FILTERS);
const clearAll = () => {
setName("");
setMinPrice("");
setMaxPrice("");
setActive([]);
};
const removeChip = (id: string) => {
setActive((prev) => prev.filter((f) => f.id !== id));
};
return (
<div dir="rtl" className="mx-auto w-full max-w-[1920px] px-4 sm:px-6 lg:px-[120px]">
<div className="flex flex-col gap-3 rounded-[5px] border border-[#818181]/40 px-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:px-4 sm:py-4">
<p className="shrink-0 text-base font-normal text-ink sm:text-[18px] lg:text-[20px]">
فیلترها
</p>
{/* justify-start packs toward inline-start = right in RTL (next to label) */}
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-start sm:gap-3">
<FilterField
label="نام محصول"
value={name}
onChange={setName}
placeholder="نام محصول"
iconSrc="/images/home/icons/search.svg"
className="sm:max-w-[269px]"
/>
<FilterField
label="حداقل قیمت"
value={minPrice}
onChange={setMinPrice}
placeholder="حداقل قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
<FilterField
label="حداکثر قیمت"
value={maxPrice}
onChange={setMaxPrice}
placeholder="حداکثر قیمت"
iconSrc="/images/home/icons/price.svg"
className="sm:max-w-[174px]"
inputMode="numeric"
/>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label="بازنشانی فیلترها"
onClick={clearAll}
className="flex size-11 items-center justify-center transition-opacity hover:opacity-70"
>
<Image
src="/images/home/icons/refresh.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
<button
type="button"
aria-label="اعمال فیلتر"
className="flex size-11 items-center justify-center rounded-[5px] bg-ink transition-opacity hover:opacity-85"
>
<Image
src="/images/home/icons/funnel.svg"
alt=""
width={22}
height={22}
unoptimized
/>
</button>
</div>
</div>
{active.length > 0 ? (
<ul className="mt-3 flex flex-wrap items-center justify-start gap-3">
{active.map((chip) => (
<li
key={chip.id}
className="inline-flex h-10 items-center gap-2 rounded-[5px] bg-[#eee] px-3"
>
<button
type="button"
aria-label={`حذف فیلتر ${chip.label}`}
onClick={() => removeChip(chip.id)}
className="shrink-0 transition-opacity hover:opacity-60"
>
<Image
src="/images/home/icons/close.svg"
alt=""
width={20}
height={20}
unoptimized
/>
</button>
<p className="text-sm font-light uppercase">
<span className="text-[#808080]">{chip.label}</span>
<span className="text-[#b4b4b4]">: </span>
<span className="text-ink">{chip.value}</span>
</p>
</li>
))}
</ul>
) : null}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import ProductCard from "@/components/home/ProductCard";
import type { Product } from "@/data/home";
type ProductGridProps = {
products: Product[];
};
export default function ProductGrid({ products }: ProductGridProps) {
return (
<ul
dir="rtl"
className="mx-auto grid w-full max-w-[1920px] grid-cols-2 gap-3 px-4 sm:grid-cols-3 sm:gap-4 sm:px-6 md:grid-cols-4 lg:grid-cols-5 lg:gap-[22px] lg:px-[120px]"
>
{products.map((product) => (
<li key={product.id} className="min-w-0">
<ProductCard product={product} className="max-w-none" />
</li>
))}
</ul>
);
}
@@ -0,0 +1,75 @@
import Image from "next/image";
import Link from "next/link";
import { LIST_PAGE_META } from "@/data/products";
type ProductListHeroProps = {
titleFa?: string;
titleEn?: string;
heroImage?: string;
breadcrumbs?: readonly { label: string; href?: string }[];
};
export default function ProductListHero({
titleFa = LIST_PAGE_META.titleFa,
titleEn = LIST_PAGE_META.titleEn,
heroImage = LIST_PAGE_META.heroImage,
breadcrumbs = LIST_PAGE_META.breadcrumbs,
}: ProductListHeroProps) {
return (
<section className="relative h-[min(52vw,469px)] min-h-[280px] w-full overflow-hidden bg-[#ebe6df]">
<Image
src={heroImage}
alt=""
fill
priority
sizes="100vw"
className="object-cover object-[center_20%]"
/>
<div className="absolute inset-0 bg-gradient-to-l from-white/20 via-transparent to-transparent" />
{/* Same inset as header / filters / grid: px-4 sm:px-6 lg:px-[120px] */}
<div className="absolute inset-x-0 bottom-5 z-10 px-4 sm:bottom-6 sm:px-6 lg:bottom-8 lg:px-[120px]">
<div className="w-full max-w-[420px] sm:max-w-[460px]">
<nav
dir="rtl"
aria-label="مسیر صفحه"
className="flex h-8 items-center justify-start gap-1.5 bg-ink px-3 text-[11px] font-light text-white sm:h-9 sm:gap-2 sm:px-4 sm:text-[13px]"
>
{breadcrumbs.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="inline-flex items-center gap-1.5 sm:gap-2">
{i > 0 ? (
<Image
src="/images/home/icons/caret-left.svg"
alt=""
width={12}
height={12}
unoptimized
className="brightness-0 invert"
/>
) : null}
{crumb.href ? (
<Link href={crumb.href} className="transition-opacity hover:opacity-70">
{crumb.label}
</Link>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
<div className="bg-white px-4 pb-2.5 pt-2 text-right sm:px-5 sm:pb-3 sm:pt-2.5">
<h1 className="text-lg font-normal leading-none text-ink sm:text-xl lg:text-[22px]">
{titleFa}
</h1>
<p
dir="ltr"
className="mt-1.5 text-right text-xs font-light leading-none uppercase tracking-[0.3em] text-[#898989] sm:text-sm sm:tracking-[0.35em]"
>
{titleEn}
</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
const PAGES = [1, 2, 3, 4, 5] as const;
const linkClass =
"px-1 py-0.5 transition-colors duration-200 hover:text-ink";
const pageClass =
"flex h-7 w-7 items-center justify-center rounded-[4px] transition-colors duration-200 sm:h-8 sm:w-8";
export default function ProductPagination() {
const [page, setPage] = useState(3);
const total = 12;
return (
<nav
dir="ltr"
aria-label="صفحه‌بندی محصولات"
className="mx-auto flex max-w-[1920px] flex-wrap items-center justify-center gap-x-3 gap-y-2 px-4 pt-2 pb-6 font-montserrat text-sm text-[#6a6a6a] sm:gap-x-4 sm:text-[15px] lg:pt-3 lg:pb-8"
>
<button type="button" onClick={() => setPage(1)} className={linkClass}>
FIRST
</button>
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={linkClass}
>
PREV
</button>
{PAGES.map((n) => (
<button
key={n}
type="button"
aria-current={page === n ? "page" : undefined}
onClick={() => setPage(n)}
className={
page === n
? `${pageClass} bg-ink text-white`
: `${pageClass} text-ink hover:bg-ink hover:text-white`
}
>
{n}
</button>
))}
<span className="text-ink" aria-hidden>
...
</span>
<button
type="button"
onClick={() => setPage(total)}
className={`${pageClass} text-ink hover:bg-ink hover:text-white`}
>
{total}
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(total, p + 1))}
className={linkClass}
>
NEXT
</button>
<button type="button" onClick={() => setPage(total)} className={linkClass}>
LAST
</button>
</nav>
);
}
+355
View File
@@ -0,0 +1,355 @@
import { categoryPath } from "@/lib/category-path";
export type Product = {
id: string | number;
nameFa: string;
nameEn: string;
price: string;
image: string;
/** Second photo revealed on card hover (slides in from the left). */
imageHover?: string;
href?: string;
};
export type Category = {
id: string;
nameFa: string;
nameEn: string;
image: string;
href?: string;
};
export type Feature = {
id: string;
title: string;
subtitle: string;
icon: string;
};
export const NAV_LINKS = [
{ label: "محصولات", href: "/products", hasDropdown: true },
{ label: "جدیدترین ها", href: "/products?sort=newest" },
{ label: "فروش ویژه", href: "/products?sale=1" },
{ label: "تماس با ما", href: "/contact" },
{ label: "درباره ما", href: "/about" },
] as const;
export const MEGA_MENU_TABS = [
{
id: "coat",
nameFa: "پالتو و کت",
nameEn: "Coats",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
image: "/images/home/products/coat-blue.jpg",
links: [
{
label: "پالتو",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت و شلوار",
href: categoryPath({ id: "coat", nameEn: "Coats", nameFa: "پالتو و کت" }),
},
{
label: "کت و دامن",
href: categoryPath({
id: "skirts",
nameEn: "Skirts",
nameFa: "دامن",
}),
},
],
},
{
id: "shirts",
nameFa: "پیراهن",
nameEn: "Shirts",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
image: "/images/home/products/coat-navy.jpg",
links: [
{
label: "پیراهن",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
label: "شومیز",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
label: "تی شرت",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
],
},
{
id: "pants",
nameFa: "شلوار",
nameEn: "Pants",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
image: "/images/home/products/coat-check.jpg",
links: [
{
label: "شلوار",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
{
label: "شلوار جین",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
],
},
{
id: "skirts",
nameFa: "دامن",
nameEn: "Skirts",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
image: "/images/home/products/coat-beige.jpg",
links: [
{
label: "دامن",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
{
label: "کت و دامن",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
],
},
{
id: "bags",
nameFa: "کیف",
nameEn: "Bags",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
image: "/images/home/banners/hero.jpg",
links: [
{
label: "کیف دستی",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
{
label: "کوله",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
],
},
{
id: "leather",
nameFa: "چرم",
nameEn: "Leather",
href: "/products?collection=leather",
image: "/images/home/banners/leather.png",
links: [
{ label: "کالکشن چرم", href: "/products?collection=leather" },
{
label: "کیف چرم",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
],
},
] as const;
export const ABOUT = {
titleFa: "درباره ما",
titleEn: "About Us",
lead: "مانا کالکشن برای بانویی که خودش انتخاب می‌کند.",
body: "ما با تمرکز روی کیفیت، دوخت و جزئیات، کالکشن‌هایی می‌سازیم که هم معاصر باشند و هم ماندگار. از چرم تا پالتوهای فصل، هر قطعه با دقت انتخاب و ارائه می‌شود تا تجربه خرید ساده، شفاف و لذت‌بخش باشد.",
image: "/images/home/banners/hero.jpg",
imageSecondary: "/images/home/products/coat-beige.jpg",
} as const;
export const CATEGORIES: Category[] = [
{
id: "shirts",
nameFa: "پیراهن",
nameEn: "Shirts",
image: "/images/home/categories/cat-6.png",
href: categoryPath({ id: "shirts", nameEn: "Shirts", nameFa: "پیراهن" }),
},
{
id: "coat",
nameFa: "کت",
nameEn: "Coat",
image: "/images/home/categories/cat-2.png",
href: categoryPath({ id: "coat", nameEn: "Coat", nameFa: "کت" }),
},
{
id: "sunglasses",
nameFa: "عینک",
nameEn: "Glasses",
image: "/images/home/categories/cat-4.png",
href: categoryPath({
id: "sunglasses",
nameEn: "Glasses",
nameFa: "عینک",
}),
},
{
id: "bags",
nameFa: "کیف",
nameEn: "Bags",
image: "/images/home/categories/cat-3.png",
href: categoryPath({ id: "bags", nameEn: "Bags", nameFa: "کیف" }),
},
{
id: "pants",
nameFa: "شلوار",
nameEn: "Pants",
image: "/images/home/categories/cat-1.png",
href: categoryPath({ id: "pants", nameEn: "Pants", nameFa: "شلوار" }),
},
{
id: "skirts",
nameFa: "دامن",
nameEn: "Skirts",
image: "/images/home/categories/cat-5.png",
href: categoryPath({ id: "skirts", nameEn: "Skirts", nameFa: "دامن" }),
},
];
export const SALE_PRODUCTS: Product[] = [
{
id: "p1",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-beige.jpg",
},
{
id: "p2",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
},
{
id: "p3",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-navy.jpg",
imageHover: "/images/home/products/coat-check.jpg",
},
{
id: "p4",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-check.jpg",
imageHover: "/images/home/products/coat-blue.jpg",
},
{
id: "p5",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-blue.jpg",
imageHover: "/images/home/products/coat-navy.jpg",
},
{
id: "p6",
nameFa: "پالتو فوتر آبی",
nameEn: "Footer Blue Palto",
price: "6.900.000",
image: "/images/home/products/coat-beige.jpg",
imageHover: "/images/home/products/coat-check.jpg",
},
];
export const AUTUMN_PRODUCTS: Product[] = SALE_PRODUCTS;
export const FEATURES: Feature[] = [
{
id: "support",
title: "پشتیبانی آنلاین",
subtitle: "شنبه تا پنج شنبه | 10:00 الی 20:00",
icon: "/images/home/features/support.svg",
},
{
id: "return",
title: "تعویض و مرجوعی",
subtitle: "تا هفت روز پس از دریافت",
icon: "/images/home/features/return.svg",
},
{
id: "authentic",
title: "ضمانت اصالت کالا",
subtitle: "تضمین کیفیت و اصالت",
icon: "/images/home/features/authentic.svg",
},
{
id: "shipping",
title: "ارسال رایگان",
subtitle: "برای خرید های بالای 4 میلیون",
icon: "/images/home/features/shipping.svg",
},
];
export const BLACK_ITEMS = [
{
id: "coat",
image: "/images/home/black/coat.png",
alt: "پالتو مشکی",
nameFa: "پالتو",
nameEn: "Coat",
},
{
id: "pants",
image: "/images/home/black/pants.png",
alt: "شلوار مشکی",
nameFa: "شلوار",
nameEn: "Pants",
},
{
id: "shirt",
image: "/images/home/black/shirt.png",
alt: "پیراهن مشکی",
nameFa: "پیراهن",
nameEn: "Shirt",
},
{
id: "bag",
image: "/images/home/black/bag.png",
alt: "کیف مشکی",
nameFa: "کیف",
nameEn: "Bag",
},
] as const;
export const FOOTER_STORE_LINKS = [
{ label: "کالکشن پاییزه", href: "/products?collection=autumn" },
{ label: "فروش ویژه", href: "/products?sale=1" },
{ label: "پر فروش ترین ها", href: "/products?sort=popular" },
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
] as const;
export const FOOTER_CATEGORIES_COL1 = [
"پالتو",
"پیراهن",
"شومیز",
"تی شرت",
"کت و دامن",
"کت و شلوار",
] as const;
export const FOOTER_CATEGORIES_COL2 = [
"کیف",
"عینک",
"جوراب",
"تی شرت",
"کت و دامن",
"کت و شلوار",
] as const;
export const SOCIAL_LINKS = [
{ id: "instagram", href: "#", icon: "/images/home/social/instagram.svg", label: "اینستاگرام" },
{ id: "telegram", href: "#", icon: "/images/home/social/telegram.svg", label: "تلگرام" },
{ id: "whatsapp", href: "#", icon: "/images/home/social/whatsapp.svg", label: "واتساپ" },
{ id: "linkedin", href: "#", icon: "/images/home/social/linkedin.svg", label: "لینکدین" },
] as const;
+99
View File
@@ -0,0 +1,99 @@
import { LIST_PAGE_META } from "./products";
import { ABOUT, CATEGORIES, type Category } from "./home";
export const ABOUT_PAGE = {
titleFa: ABOUT.titleFa,
titleEn: ABOUT.titleEn,
heroImage: LIST_PAGE_META.heroImage,
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "درباره ما" },
],
lead: ABOUT.lead,
body: ABOUT.body,
image: ABOUT.image,
imageSecondary: ABOUT.imageSecondary,
storyTitleFa: "داستان ما",
storyTitleEn: "Our story",
story:
"مانا کالکشن از علاقه به لباس‌هایی شکل گرفت که هم زیبا باشند و هم واقعاً پوشیده شوند. ما با تولیدکننده‌ها و طراحانی کار می‌کنیم که به جزئیات، جنس پارچه و دوام اهمیت می‌دهند — تا هر انتخاب شما، انتخابی مطمئن باشد.",
values: [
{
titleFa: "کیفیت ساخت",
titleEn: "Craft",
body: "انتخاب پارچه، دوخت و فیتینگ با استانداردی که برای استفاده روزمره و ماندگاری طراحی شده است.",
},
{
titleFa: "انتخاب آگاهانه",
titleEn: "Choice",
body: "اطلاعات شفاف درباره سایز، جنس و مراقبت — تا خرید، بدون حدس و پشیمانی باشد.",
},
{
titleFa: "تجربه ساده",
titleEn: "Experience",
body: "از مرور کالکشن تا پشتیبانی پس از خرید، مسیر را کوتاه، روشن و قابل اعتماد نگه می‌داریم.",
},
],
} as const;
export const CONTACT_PAGE = {
titleFa: "تماس با ما",
titleEn: "Contact Us",
heroImage: LIST_PAGE_META.heroImage,
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "تماس با ما" },
],
lead: "سوال، پیشنهاد یا پیگیری سفارش؟ پیام بگذارید — تیم مانا پاسخ می‌دهد.",
address: "تهران، خیابان ولیعصر بالاتر از پارک ساعی، پلاک ۲۴۸",
phoneStore: "021-22423600",
phoneMobile: "09122504800",
email: "Info@ManaCollecitions.com",
hours: "شنبه تا پنج‌شنبه | ۱۰:۰۰ الی ۲۰:۰۰",
} as const;
export type CategoryPageMeta = Category & {
description: string;
heroImage: string;
};
const CATEGORY_EXTRA: Record<
string,
{ description: string; heroImage?: string }
> = {
shirts: {
description: "پیراهن‌ها و شومیزهایی برای روزمره تا استایل رسمی‌تر.",
},
coat: {
description: "کت و پالتوهای فصل با برش‌های تمیز و ایستایی دقیق.",
heroImage: "/images/home/products/coat-blue.jpg",
},
sunglasses: {
description: "اکسسوری‌هایی که استایل را کامل می‌کنند.",
},
bags: {
description: "کیف‌های روزمره و رسمی با تمرکز روی جنس و کاربرد.",
},
pants: {
description: "شلوار و جین با فیت‌های متنوع برای استایل روزمره.",
heroImage: "/images/home/products/coat-check.jpg",
},
skirts: {
description: "دامن‌هایی برای ترکیب با کت، شومیز یا بافت فصل.",
},
};
export function getCategoryPage(slug: string): CategoryPageMeta | null {
const base = CATEGORIES.find((c) => c.id === slug);
if (!base) return null;
const extra = CATEGORY_EXTRA[slug];
return {
...base,
description:
extra?.description ||
`مجموعه‌ای از محصولات ${base.nameFa} در مانا کالکشن.`,
heroImage: extra?.heroImage || LIST_PAGE_META.heroImage,
};
}
export const CATEGORY_SLUGS = CATEGORIES.map((c) => c.id);
+162
View File
@@ -0,0 +1,162 @@
import { SALE_PRODUCTS, type Product } from "./home";
import { productPath } from "@/lib/product-path";
export type ProductVariantOption = {
id: string;
labelFa: string;
labelEn: string;
/** Hex or image for color chips */
swatch?: string;
available?: boolean;
};
export type ProductComment = {
id: string;
author: string;
date: string;
rating: number;
body: string;
};
export type ProductDetail = Product & {
summary: string;
description: string;
gallery: string[];
colors: ProductVariantOption[];
sizes: ProductVariantOption[];
packages: ProductVariantOption[];
technical: Array<{ labelFa: string; labelEn: string; value: string }>;
comments: ProductComment[];
sizeGuideImage: string | null;
sizeGuideNote: string;
breadcrumbs: Array<{ label: string; href?: string }>;
};
const GALLERY = [
"/images/home/products/coat-blue.jpg",
"/images/home/products/coat-beige.jpg",
"/images/home/products/coat-navy.jpg",
"/images/home/products/coat-check.jpg",
];
const COLORS: ProductVariantOption[] = [
{ id: "blue", labelFa: "آبی", labelEn: "Blue", swatch: "#2f4a6e" },
{ id: "beige", labelFa: "بژ", labelEn: "Beige", swatch: "#c4b09a" },
{ id: "navy", labelFa: "سرمه‌ای", labelEn: "Navy", swatch: "#1a2744" },
{
id: "check",
labelFa: "چهارخانه",
labelEn: "Check",
swatch: "#6b6358",
available: false,
},
];
const SIZES: ProductVariantOption[] = [
{ id: "s", labelFa: "S", labelEn: "S" },
{ id: "m", labelFa: "M", labelEn: "M" },
{ id: "l", labelFa: "L", labelEn: "L" },
{ id: "xl", labelFa: "XL", labelEn: "XL" },
];
const PACKAGES: ProductVariantOption[] = [
{ id: "std", labelFa: "استاندارد", labelEn: "Standard" },
{ id: "gift", labelFa: "کادویی", labelEn: "Gift wrap" },
];
const TECHNICAL = [
{ labelFa: "جنس", labelEn: "Fabric", value: "فوتر پشمی با آستر ساتن" },
{ labelFa: "قد", labelEn: "Length", value: "۹۵ سانتی‌متر (سایز M)" },
{ labelFa: "یقه", labelEn: "Collar", value: "یقه ایستاده" },
{ labelFa: "بسته‌شدن", labelEn: "Closure", value: "دکمه مخفی + کمربند" },
{ labelFa: "مراقبت", labelEn: "Care", value: "خشکشویی تخصصی" },
{ labelFa: "کشور تولید", labelEn: "Origin", value: "ایران" },
];
const COMMENTS: ProductComment[] = [
{
id: "c1",
author: "سارا م.",
date: "۱۴۰۴/۰۶/۱۲",
rating: 5,
body: "کیفیت دوخت عالی بود و رنگ دقیقاً همان چیزی بود که در سایت دیدم. سایز M برای قد ۱۶۵ مناسب است.",
},
{
id: "c2",
author: "نیکا ر.",
date: "۱۴۰۴/۰۵/۲۸",
rating: 4,
body: "پارچه گرم و لطیف است. فقط کمی دیر به دستم رسید ولی پشتیبانی پاسخگو بود.",
},
{
id: "c3",
author: "آیدا ک.",
date: "۱۴۰۴/۰۴/۰۳",
rating: 5,
body: "برای فصل پاییز عالیه. راهنمای سایز کمک کرد انتخاب درستی داشته باشم.",
},
];
function buildDetail(base: Product, index: number): ProductDetail {
const gallery = [
base.image,
base.imageHover ?? GALLERY[(index + 1) % GALLERY.length],
GALLERY[(index + 2) % GALLERY.length],
GALLERY[(index + 3) % GALLERY.length],
].filter((src, i, arr) => arr.indexOf(src) === i);
return {
...base,
href: productPath(base),
summary:
"پالتوی فوتر با برش کلاسیک و جزئیات دقیق — مناسب روزهای خنک پاییز و زمستان.",
description:
"این پالتو با تمرکز روی کیفیت دوخت و ایستایی روی اندام طراحی شده است. آستر داخلی نرم، دکمه‌های مخفی و کمربند جداشدنی به شما آزادی استایل می‌دهد. با شلوار جین، دامن میدی یا ست رسمی به‌راحتی ست می‌شود.",
gallery,
colors: COLORS,
sizes: SIZES,
packages: PACKAGES,
technical: TECHNICAL,
comments: COMMENTS,
sizeGuideImage: "/images/home/banners/leather.png",
sizeGuideNote:
"اندازه‌ها بر حسب سانتی‌متر است. اگر بین دو سایز هستید، سایز بزرگ‌تر را انتخاب کنید.",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "محصولات", href: "/products" },
{ label: "پالتو و کت", href: "/products?cat=coat" },
{ label: base.nameFa },
],
};
}
export const PRODUCT_DETAILS: Record<string, ProductDetail> = Object.fromEntries(
SALE_PRODUCTS.map((p, i) => [p.id, buildDetail(p, i)]),
);
export function getProductDetail(id: string): ProductDetail | null {
if (PRODUCT_DETAILS[id]) return PRODUCT_DETAILS[id];
// list-* ids from products page map back to base sale products
const listMatch = /^list-(\d+)$/.exec(id);
if (listMatch) {
const n = Number(listMatch[1]) - 1;
const base = SALE_PRODUCTS[n % SALE_PRODUCTS.length];
return buildDetail({ ...base, id }, n);
}
return null;
}
export function getSimilarProducts(id: string, limit = 8): Product[] {
const others = SALE_PRODUCTS.filter((p) => p.id !== id);
const pool = others.length ? others : SALE_PRODUCTS;
return Array.from({ length: limit }, (_, i) => {
const base = pool[i % pool.length];
return {
...base,
id: `similar-${id}-${i}`,
href: productPath(base),
};
});
}
export const PRODUCT_DETAIL_IDS = Object.keys(PRODUCT_DETAILS);
+25
View File
@@ -0,0 +1,25 @@
import { SALE_PRODUCTS, type Product } from "./home";
import { productPath } from "@/lib/product-path";
export type { Product };
/** Expand home products into a denser list for the products page grid. */
export const LIST_PRODUCTS: Product[] = Array.from({ length: 15 }, (_, i) => {
const base = SALE_PRODUCTS[i % SALE_PRODUCTS.length];
return {
...base,
id: `list-${i + 1}`,
href: productPath(base),
};
});
export const LIST_PAGE_META = {
titleFa: "شلوار جین",
titleEn: "Jean Pants",
heroImage: "/images/home/banners/hero.jpg",
breadcrumbs: [
{ label: "خانه", href: "/" },
{ label: "لیست محصولات", href: "/products" },
{ label: "دسته بندی شلوار جین", href: "/products?cat=jeans" },
],
} as const;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
import type { Category } from "@/data/home";
import { CATEGORIES, MEGA_MENU_TABS } from "@/data/home";
import type { MegaTabData } from "@/components/home/ProductsMegaMenu";
import { categoryPath } from "@/lib/category-path";
import { publicFetch, STORE_SLUG } from "@/lib/public-api";
export type ApiCategoryNode = {
id: number;
parentId: number | null;
nameFa: string;
nameEn: string;
sortOrder: number;
imageUrl: string | null;
children: ApiCategoryNode[];
};
const FALLBACK_CAT_IMAGE = "/images/home/categories/cat-1.png";
export async function fetchPublicCategories(): Promise<ApiCategoryNode[]> {
const data = await publicFetch<{
storeSlug: string;
categories: ApiCategoryNode[];
}>(`/public/categories?storeSlug=${encodeURIComponent(STORE_SLUG)}`);
return data?.categories ?? [];
}
export function mapCategoriesToStrip(
nodes: ApiCategoryNode[],
): Category[] {
if (!nodes.length) return CATEGORIES;
return nodes.map((node) => ({
id: String(node.id),
nameFa: node.nameFa,
nameEn: node.nameEn || node.nameFa,
image: node.imageUrl || FALLBACK_CAT_IMAGE,
href: categoryPath(node),
}));
}
export function mapCategoriesToMegaTabs(
nodes: ApiCategoryNode[],
slotImages?: Array<{ image: string; nameFa?: string; href?: string }>,
): MegaTabData[] {
if (!nodes.length) return [...MEGA_MENU_TABS];
return nodes.map((node, index) => {
const slot = slotImages?.[index];
const children = node.children ?? [];
const columns =
children.length > 0
? children.map((child) => {
const grand = child.children ?? [];
return {
title: child.nameFa,
href: categoryPath(child),
links:
grand.length > 0
? grand.map((g) => ({
label: g.nameFa,
href: categoryPath(g),
}))
: [],
};
})
: [
{
title: node.nameFa,
href: categoryPath(node),
links: [],
},
];
// Flat links kept for any consumer still reading them
const links = columns.flatMap((col) =>
col.links.length
? col.links
: [{ label: col.title, href: col.href }],
);
return {
id: String(node.id),
nameFa: node.nameFa,
nameEn: node.nameEn || node.nameFa,
href: categoryPath(node),
image: slot?.image || node.imageUrl || FALLBACK_CAT_IMAGE,
columns,
links,
};
});
}

Some files were not shown because too many files have changed in this diff Show More