This commit is contained in:
amirhosein.ashourloo
2026-08-19 17:54:11 +03:30
parent b065fac757
commit 149b77f734
80 changed files with 3015 additions and 92 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "simode-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000,
"autoPort": true
}
]
}
+15
View File
@@ -0,0 +1,15 @@
Domain: simode.shop
Documentation for backend is:https://api.meshkee.com/docs/website
Login or user icon should go to customer.simode.shop
Create product page and details >> connect to store items
Create blog page and details >> connect to blog
Product Menu should be a mega menu with level 2 and 3 of categories - skip level 1
Search input should be instant search - activate when type more than 2 char
First carousel >> special-discount
Second carousel >> top-selling
+13
View File
@@ -0,0 +1,13 @@
Wire this page to simode.shop static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- two-banner-row > two slot
- one-banner-row → one slot
- aboutus-row → one slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
+21 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"swiper": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -6258,6 +6259,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-14.1.0.tgz",
"integrity": "sha512-ppit/AqmGvThKlXy4I1/9N/gEyb9s/BDHWciWNeIxbfteFKUhPQXrYuOMKszomXKd9WiLd+/0vKdPHW+0N0mhA==",
"funding": [
{
"type": "custom",
"url": "https://sponsors.nolimits4web.com"
},
{
"type": "github",
"url": "https://github.com/sponsors/nolimits4web"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+2 -1
View File
@@ -11,7 +11,8 @@
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"swiper": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 640" width="720" height="640">
<rect width="720" height="640" fill="#E4E7E2" />
<g transform="translate(289.6 232.70399999999998)" fill="none" stroke="#B9BEB4" stroke-width="6.336" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="140.8" height="101.376" rx="8.448" />
<circle cx="33.792" cy="33.792" r="9.856000000000002" />
<path d="M0 81.664 L45.056000000000004 45.056000000000004 L78.84800000000001 70.4 L109.82400000000001 42.24 L140.8 70.4" />
</g>
<text x="360" y="438.272" text-anchor="middle" font-family="Arial, sans-serif" font-size="22.528000000000002" fill="#9AA096" letter-spacing="1">ABOUT US · 720×640</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 770 360" width="770" height="360">
<rect width="770" height="360" fill="#EBEDE9" />
<g transform="translate(345.4 130.89600000000002)" fill="none" stroke="#B9BEB4" stroke-width="3.564" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="79.2" height="57.024" rx="4.752" />
<circle cx="19.008" cy="19.008" r="5.5440000000000005" />
<path d="M0 45.936 L25.344 25.344 L44.352000000000004 39.6 L61.776 23.76 L79.2 39.6" />
</g>
<text x="385" y="246.52800000000002" text-anchor="middle" font-family="Arial, sans-serif" font-size="12.672" fill="#9AA096" letter-spacing="1">BANNER · 770×360</text>
</svg>

After

Width:  |  Height:  |  Size: 706 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 770 360" width="770" height="360">
<rect width="770" height="360" fill="#E4E7E2" />
<g transform="translate(345.4 130.89600000000002)" fill="none" stroke="#B9BEB4" stroke-width="3.564" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="79.2" height="57.024" rx="4.752" />
<circle cx="19.008" cy="19.008" r="5.5440000000000005" />
<path d="M0 45.936 L25.344 25.344 L44.352000000000004 39.6 L61.776 23.76 L79.2 39.6" />
</g>
<text x="385" y="246.52800000000002" text-anchor="middle" font-family="Arial, sans-serif" font-size="12.672" fill="#9AA096" letter-spacing="1">BANNER · 770×360</text>
</svg>

After

Width:  |  Height:  |  Size: 706 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 420" width="1600" height="420">
<rect width="1600" height="420" fill="#EBEDE9" />
<g transform="translate(753.8 152.712)" fill="none" stroke="#B9BEB4" stroke-width="4.158" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="92.4" height="66.528" rx="5.5440000000000005" />
<circle cx="22.176000000000002" cy="22.176000000000002" r="6.468000000000001" />
<path d="M0 53.592 L29.568 29.568 L51.74400000000001 46.2 L72.072 27.720000000000002 L92.4 46.2" />
</g>
<text x="800" y="287.616" text-anchor="middle" font-family="Arial, sans-serif" font-size="14.784" fill="#9AA096" letter-spacing="1">FULL BANNER · 1600×420</text>
</svg>

After

Width:  |  Height:  |  Size: 741 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 380" width="520" height="380">
<rect width="520" height="380" fill="#EBEDE9" />
<g transform="translate(218.2 138.168)" fill="none" stroke="#B9BEB4" stroke-width="3.7619999999999996" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="83.6" height="60.19199999999999" rx="5.015999999999999" />
<circle cx="20.063999999999997" cy="20.063999999999997" r="5.852" />
<path d="M0 48.48799999999999 L26.752 26.752 L46.816 41.8 L65.208 25.08 L83.6 41.8" />
</g>
<text x="260" y="260.224" text-anchor="middle" font-family="Arial, sans-serif" font-size="13.376" fill="#9AA096" letter-spacing="1">BLOG · 520×380</text>
</svg>

After

Width:  |  Height:  |  Size: 728 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 380" width="520" height="380">
<rect width="520" height="380" fill="#E4E7E2" />
<g transform="translate(218.2 138.168)" fill="none" stroke="#B9BEB4" stroke-width="3.7619999999999996" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="83.6" height="60.19199999999999" rx="5.015999999999999" />
<circle cx="20.063999999999997" cy="20.063999999999997" r="5.852" />
<path d="M0 48.48799999999999 L26.752 26.752 L46.816 41.8 L65.208 25.08 L83.6 41.8" />
</g>
<text x="260" y="260.224" text-anchor="middle" font-family="Arial, sans-serif" font-size="13.376" fill="#9AA096" letter-spacing="1">BLOG · 520×380</text>
</svg>

After

Width:  |  Height:  |  Size: 728 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 380" width="520" height="380">
<rect width="520" height="380" fill="#EBEDE9" />
<g transform="translate(218.2 138.168)" fill="none" stroke="#B9BEB4" stroke-width="3.7619999999999996" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="83.6" height="60.19199999999999" rx="5.015999999999999" />
<circle cx="20.063999999999997" cy="20.063999999999997" r="5.852" />
<path d="M0 48.48799999999999 L26.752 26.752 L46.816 41.8 L65.208 25.08 L83.6 41.8" />
</g>
<text x="260" y="260.224" text-anchor="middle" font-family="Arial, sans-serif" font-size="13.376" fill="#9AA096" letter-spacing="1">BLOG · 520×380</text>
</svg>

After

Width:  |  Height:  |  Size: 728 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 380" width="520" height="380">
<rect width="520" height="380" fill="#E4E7E2" />
<g transform="translate(218.2 138.168)" fill="none" stroke="#B9BEB4" stroke-width="3.7619999999999996" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="83.6" height="60.19199999999999" rx="5.015999999999999" />
<circle cx="20.063999999999997" cy="20.063999999999997" r="5.852" />
<path d="M0 48.48799999999999 L26.752 26.752 L46.816 41.8 L65.208 25.08 L83.6 41.8" />
</g>
<text x="260" y="260.224" text-anchor="middle" font-family="Arial, sans-serif" font-size="13.376" fill="#9AA096" letter-spacing="1">BLOG · 520×380</text>
</svg>

After

Width:  |  Height:  |  Size: 728 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#EBEDE9" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#E4E7E2" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#EBEDE9" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#E4E7E2" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#E4E7E2" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 600" width="520" height="600">
<rect width="520" height="600" fill="#EBEDE9" />
<g transform="translate(202.8 229.072)" fill="none" stroke="#B9BEB4" stroke-width="5.148" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="114.4" height="82.368" rx="6.864" />
<circle cx="27.456" cy="27.456" r="8.008000000000001" />
<path d="M0 66.352 L36.608000000000004 36.608000000000004 L64.06400000000001 57.2 L89.23200000000001 34.32 L114.4 57.2" />
</g>
<text x="260" y="396.096" text-anchor="middle" font-family="Arial, sans-serif" font-size="18.304000000000002" fill="#9AA096" letter-spacing="1">CATEGORY · 520×600</text>
</svg>

After

Width:  |  Height:  |  Size: 733 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 560" width="1600" height="560">
<rect width="1600" height="560" fill="#EBEDE9" />
<g transform="translate(738.4 203.616)" fill="none" stroke="#B9BEB4" stroke-width="5.544" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="123.2" height="88.704" rx="7.3919999999999995" />
<circle cx="29.567999999999998" cy="29.567999999999998" r="8.624" />
<path d="M0 71.456 L39.424 39.424 L68.992 61.6 L96.096 36.96 L123.2 61.6" />
</g>
<text x="800" y="383.48800000000006" text-anchor="middle" font-family="Arial, sans-serif" font-size="19.712" fill="#9AA096" letter-spacing="1">HERO SLIDE · 1600×560</text>
</svg>

After

Width:  |  Height:  |  Size: 717 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 560" width="1600" height="560">
<rect width="1600" height="560" fill="#E4E7E2" />
<g transform="translate(738.4 203.616)" fill="none" stroke="#B9BEB4" stroke-width="5.544" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="123.2" height="88.704" rx="7.3919999999999995" />
<circle cx="29.567999999999998" cy="29.567999999999998" r="8.624" />
<path d="M0 71.456 L39.424 39.424 L68.992 61.6 L96.096 36.96 L123.2 61.6" />
</g>
<text x="800" y="383.48800000000006" text-anchor="middle" font-family="Arial, sans-serif" font-size="19.712" fill="#9AA096" letter-spacing="1">HERO SLIDE · 1600×560</text>
</svg>

After

Width:  |  Height:  |  Size: 717 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 560" width="1600" height="560">
<rect width="1600" height="560" fill="#EBEDE9" />
<g transform="translate(738.4 203.616)" fill="none" stroke="#B9BEB4" stroke-width="5.544" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="123.2" height="88.704" rx="7.3919999999999995" />
<circle cx="29.567999999999998" cy="29.567999999999998" r="8.624" />
<path d="M0 71.456 L39.424 39.424 L68.992 61.6 L96.096 36.96 L123.2 61.6" />
</g>
<text x="800" y="383.48800000000006" text-anchor="middle" font-family="Arial, sans-serif" font-size="19.712" fill="#9AA096" letter-spacing="1">HERO SLIDE · 1600×560</text>
</svg>

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">PRODUCT · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE BANNER · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 748 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#EBEDE9" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 620" width="480" height="620">
<rect width="480" height="620" fill="#E4E7E2" />
<g transform="translate(187.2 244.52799999999996)" fill="none" stroke="#B9BEB4" stroke-width="4.752" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="105.6" height="76.032" rx="6.335999999999999" />
<circle cx="25.343999999999998" cy="25.343999999999998" r="7.392" />
<path d="M0 61.24799999999999 L33.792 33.792 L59.136 52.8 L82.368 31.679999999999996 L105.6 52.8" />
</g>
<text x="240" y="398.70399999999995" text-anchor="middle" font-family="Arial, sans-serif" font-size="16.896" fill="#9AA096" letter-spacing="1">SALE ITEM · 480×620</text>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+85
View File
@@ -0,0 +1,85 @@
// Generates flat, neutral placeholder SVG images so every image slot on the
// homepage has a real file on disk that can be swapped later without any
// code changes. Run with: node scripts/gen-placeholders.mjs
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC = join(__dirname, "..", "public", "images");
// Two flat, muted tones so placeholders alternate subtly instead of being
// one flat wall of gray.
const TONES = ["#EBEDE9", "#E4E7E2"];
function icon(cx, cy, s, stroke) {
// Simple flat "image" glyph: rounded frame + mountain + sun dot.
return `
<g transform="translate(${cx - s / 2} ${cy - s / 2})" fill="none" stroke="${stroke}" stroke-width="${Math.max(
2,
s * 0.045
)}" stroke-linejoin="round" stroke-linecap="round">
<rect x="0" y="0" width="${s}" height="${s * 0.72}" rx="${s * 0.06}" />
<circle cx="${s * 0.24}" cy="${s * 0.24}" r="${s * 0.07}" />
<path d="M0 ${s * 0.58} L${s * 0.32} ${s * 0.32} L${s * 0.56} ${s * 0.5} L${s * 0.78} ${s * 0.3} L${s} ${
s * 0.5
}" />
</g>`;
}
function svgPlaceholder({ w, h, label, tone }) {
const s = Math.min(w, h) * 0.22;
const cx = w / 2;
const cy = h / 2 - s * 0.12;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
<rect width="${w}" height="${h}" fill="${tone}" />
${icon(cx, cy, s, "#B9BEB4")}
<text x="${cx}" y="${cy + s * 0.62 + s * 0.34}" text-anchor="middle" font-family="Arial, sans-serif" font-size="${Math.max(
11,
s * 0.16
)}" fill="#9AA096" letter-spacing="1">${label} · ${w}×${h}</text>
</svg>`;
}
let toneIndex = 0;
function nextTone() {
const t = TONES[toneIndex % TONES.length];
toneIndex += 1;
return t;
}
function write(relPath, w, h, label) {
const full = join(PUBLIC, relPath);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, svgPlaceholder({ w, h, label, tone: nextTone() }), "utf8");
console.log("wrote", relPath);
}
// Hero slider — full width slides
for (let i = 1; i <= 3; i++) write(`hero/slide-${i}.svg`, 1600, 560, "HERO SLIDE");
// Special sale carousel — first card is a banner image, rest are product shots
write("special-sale/banner.svg", 480, 620, "SALE BANNER");
for (let i = 1; i <= 8; i++) write(`special-sale/product-${i}.svg`, 480, 620, "SALE ITEM");
// Categories
const categories = ["girls", "boys", "baby", "party", "sets", "outerwear"];
for (const key of categories) write(`categories/${key}.svg`, 520, 600, "CATEGORY");
// Two-banner section
write("banners/banner-1.svg", 770, 360, "BANNER");
write("banners/banner-2.svg", 770, 360, "BANNER");
// Single full-width banner
write("banners/full-banner.svg", 1600, 420, "FULL BANNER");
// Top selling products carousel
for (let i = 1; i <= 12; i++) write(`products/product-${i}.svg`, 480, 620, "PRODUCT");
// About us
write("about/about.svg", 720, 640, "ABOUT US");
// Blog carousel
for (let i = 1; i <= 4; i++) write(`blog/post-${i}.svg`, 520, 380, "BLOG");
console.log("Done. Placeholder images generated in public/images/");
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBlog } from "@/lib/api";
export default async function BlogDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
let blog;
try {
({ blog } = await getBlog(slug));
} catch {
notFound();
}
const title = blog.title || blog.nameFa || "";
const cover = blog.thumbnailUrl || blog.image;
const date = blog.publishedAt || blog.createdAt;
const content = blog.contentHtml || blog.descriptionHtml;
return (
<main className="boxed py-8 md:py-12">
<nav className="mb-6 text-xs md:text-sm text-body">
<Link href="/blog" className="hover:text-brand transition-colors">
وبلاگ
</Link>
</nav>
<article className="max-w-3xl mx-auto">
{date && (
<span className="font-en text-xs tracking-wider text-gold-dark font-semibold">
{date.slice(0, 10)}
</span>
)}
<h1 className="mt-2 text-xl md:text-3xl font-extrabold text-ink">{title}</h1>
{cover && (
<img
src={cover}
alt=""
className="mt-6 w-full aspect-[16/9] object-cover rounded-xl border border-line"
/>
)}
{blog.summary && <p className="mt-6 text-body leading-8">{blog.summary}</p>}
{content && (
<div
className="mt-6 rich-text text-body leading-8"
dangerouslySetInnerHTML={{ __html: content }}
/>
)}
</article>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from "next/link";
import { getBlogs, safe } from "@/lib/api";
import Pagination from "@/components/ui/Pagination";
const PAGE_SIZE = 12;
export const metadata = {
title: "وبلاگ | کودکانه",
};
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const result = await safe(getBlogs({ page, pageSize: PAGE_SIZE }), {
items: [],
total: 0,
page,
pageSize: PAGE_SIZE,
});
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
return (
<main className="boxed py-8 md:py-12">
<div className="mb-8">
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
Our Blog
</span>
<h1 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">مقالات وبلاگ</h1>
</div>
{result.items.length === 0 ? (
<p className="py-16 text-center text-body">
هنوز مطلبی در وبلاگ منتشر نشده است. به‌زودی برمی‌گردیم.
</p>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{result.items.map((post) => {
const date = post.publishedAt || post.createdAt;
return (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
>
<img
src={post.thumbnailUrl || post.image || "/images/blog/post-1.svg"}
alt=""
className="w-full aspect-[4/3] object-cover"
/>
<div className="flex flex-1 flex-col p-4">
{date && (
<span className="font-en text-[11px] tracking-wider text-gold-dark font-semibold">
{date.slice(0, 10)}
</span>
)}
<h2 className="mt-2 text-sm md:text-[15px] font-bold text-ink leading-6 line-clamp-2 group-hover:text-brand transition-colors">
{post.title || post.nameFa}
</h2>
{post.summary && (
<p className="mt-2 text-xs md:text-[13px] leading-6 text-body line-clamp-2">
{post.summary}
</p>
)}
</div>
</Link>
);
})}
</div>
<Pagination
page={page}
totalPages={totalPages}
buildHref={(p) => `/blog${p > 1 ? `?page=${p}` : ""}`}
/>
</>
)}
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
import localFont from "next/font/local";
// Site-provided IRANYekan files (public/fonts) — light for body copy, bold
// for headings. Both register under one CSS variable so font-weight alone
// picks the right file.
export const iranYekan = localFont({
src: [
{
path: "../../public/fonts/iranyekanweblightfanum.woff",
weight: "300",
style: "normal",
},
{
path: "../../public/fonts/iranyekanwebboldfanum.woff",
weight: "700",
style: "normal",
},
],
variable: "--font-fa",
display: "swap",
});
+114 -11
View File
@@ -2,25 +2,128 @@
:root {
--background: #ffffff;
--foreground: #171717;
--foreground: #232323;
/* Brand palette — lifted from the reference store (deep green + gold on
off-white, dark ink for header/footer). Flat, no gradients. */
--color-brand: #00593d;
--color-brand-dark: #013d2a;
--color-brand-light: #e7f0ec;
--color-ink: #232323;
--color-ink-soft: #3a3a3a;
--color-body: #5c6873;
--color-gold: #d3a907;
--color-gold-dark: #a98704;
--color-surface: #f9f9f9;
--color-line: #e7e7e7;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-brand: var(--color-brand);
--color-brand-dark: var(--color-brand-dark);
--color-brand-light: var(--color-brand-light);
--color-ink: var(--color-ink);
--color-ink-soft: var(--color-ink-soft);
--color-body: var(--color-body);
--color-gold: var(--color-gold);
--color-gold-dark: var(--color-gold-dark);
--color-surface: var(--color-surface);
--color-line: var(--color-line);
--font-fa: var(--font-fa);
--font-en: var(--font-en);
--container-boxed: 1320px;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
color: var(--color-body);
font-family: var(--font-fa), Tahoma, sans-serif;
font-weight: 300;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--color-ink);
}
.font-en {
font-family: var(--font-en), sans-serif;
}
.boxed {
width: 100%;
max-width: var(--container-boxed);
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 768px) {
.boxed {
padding-inline: 1.5rem;
}
}
/* Swiper flat-styling overrides */
.swiper {
overflow: visible;
}
.swiper-pagination-bullet {
background: var(--color-line) !important;
opacity: 1 !important;
width: 7px !important;
height: 7px !important;
}
.swiper-pagination-bullet-active {
background: var(--color-brand) !important;
width: 20px !important;
border-radius: 4px !important;
}
.hero-swiper .swiper-pagination-bullet {
background: rgba(255, 255, 255, 0.55) !important;
}
.hero-swiper .swiper-pagination-bullet-active {
background: var(--color-gold) !important;
}
/* Minimal styling for CMS-authored HTML (product/blog descriptions) */
.rich-text p {
margin-bottom: 1em;
}
.rich-text ul,
.rich-text ol {
margin-bottom: 1em;
padding-inline-start: 1.25em;
}
.rich-text li {
margin-bottom: 0.4em;
}
.rich-text a {
color: var(--color-brand);
text-decoration: underline;
}
.rich-text img {
max-width: 100%;
border-radius: 0.75rem;
margin-block: 1em;
}
.rich-text strong {
color: var(--color-ink);
}
+23 -14
View File
@@ -1,29 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Rajdhani } from "next/font/google";
import "./globals.css";
import { iranYekan } from "./fonts";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { getCategories, safe } from "@/lib/api";
import { getMegaMenuColumns } from "@/lib/categoryTree";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const rajdhani = Rajdhani({
variable: "--font-en",
subsets: ["latin"],
weight: ["500", "600", "700"],
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "کودکانه | پوشاک بچگانه",
description: "فروشگاه اینترنتی پوشاک بچگانه، دخترانه و پسرانه با جدیدترین مدل‌ها",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default async function RootLayout({ children }: LayoutProps<"/">) {
const categoryColumns = await safe(getCategories().then(getMegaMenuColumns), []);
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="fa"
dir="rtl"
className={`${iranYekan.variable} ${rajdhani.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col bg-white text-ink">
<Header categoryColumns={categoryColumns} />
{children}
<Footer categoryColumns={categoryColumns} />
</body>
</html>
);
}
+46 -65
View File
@@ -1,69 +1,50 @@
import Image from "next/image";
import HeroSlider from "@/components/HeroSlider";
import SpecialSaleCarousel from "@/components/SpecialSaleCarousel";
import Categories from "@/components/Categories";
import TwoBanners from "@/components/TwoBanners";
import TopSellingCarousel from "@/components/TopSellingCarousel";
import FullBanner from "@/components/FullBanner";
import AboutUs from "@/components/AboutUs";
import BlogCarousel from "@/components/BlogCarousel";
import { getBlogs, getCategories, getProducts, getStaticImageSlots, findSlot, safe } from "@/lib/api";
import { getMegaMenuColumns } from "@/lib/categoryTree";
export default async function Home() {
const [categoriesFlat, special, topSelling, blogs, slots] = await Promise.all([
safe(getCategories(), []),
safe(getProducts({ tag: "special-discount", pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }),
safe(getProducts({ tag: "top-selling", pageSize: 12 }), { items: [], total: 0, page: 1, pageSize: 12 }),
safe(getBlogs({ pageSize: 4 }), { items: [], total: 0, page: 1, pageSize: 4 }),
safe(getStaticImageSlots("home"), []),
]);
const categoryColumns = getMegaMenuColumns(categoriesFlat);
// The merchandising tags may not be assigned to any product yet — fall
// back to the latest published products so the carousels stay populated.
const [specialProducts, topSellingProducts] = await Promise.all([
special.items.length > 0
? Promise.resolve(special.items)
: safe(getProducts({ pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }).then(
(r) => r.items
),
topSelling.items.length > 0
? Promise.resolve(topSelling.items)
: safe(getProducts({ pageSize: 12 }), { items: [], total: 0, page: 1, pageSize: 12 }).then(
(r) => r.items
),
]);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<main>
<HeroSlider slot={findSlot(slots, "slider")} />
<SpecialSaleCarousel products={specialProducts} />
<Categories categoryColumns={categoryColumns} />
<TwoBanners slot={findSlot(slots, "two-banner-row")} />
<TopSellingCarousel products={topSellingProducts} />
<FullBanner slot={findSlot(slots, "one-banner-row")} />
<AboutUs slot={findSlot(slots, "aboutus-row")} />
<BlogCarousel posts={blogs.items} />
</main>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
getComments,
getProduct,
getProductTechnicalInfo,
getStoreItemsByProduct,
safe,
} from "@/lib/api";
import ProductGallery from "@/components/ProductGallery";
import ProductBuyPanel from "@/components/ProductBuyPanel";
import ProductInfoTabs from "@/components/ProductInfoTabs";
import ProductCard from "@/components/ui/ProductCard";
import SectionHeading from "@/components/ui/SectionHeading";
export default async function ProductDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
let data;
try {
data = await getProduct(slug);
} catch {
notFound();
}
const { product, relatedProducts } = data;
const [{ items: storeItems }, technicalInfo, { items: comments }] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }),
safe(getProductTechnicalInfo(slug), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
]);
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
...product.images.map((img) => img.url),
].filter((url, i, all) => all.indexOf(url) === i);
const title = product.nameFa || product.title;
return (
<main className="boxed py-8 md:py-12">
<nav className="mb-6 text-xs md:text-sm text-body">
<Link href="/products" className="hover:text-brand transition-colors">
محصولات
</Link>
{product.categoryNameFa && (
<>
<span className="mx-1.5">/</span>
<span>{product.categoryNameFa}</span>
</>
)}
</nav>
<div className="grid grid-cols-1 lg:grid-cols-[380px_1fr] gap-8 md:gap-10">
<ProductGallery images={images} title={title} />
<ProductBuyPanel product={product} items={storeItems} />
</div>
<ProductInfoTabs
title={title}
descriptionHtml={product.descriptionHtml}
technicalValues={technicalInfo.values}
comments={comments}
/>
{relatedProducts.length > 0 && (
<div className="mt-14 md:mt-20">
<SectionHeading kicker="Related" title="محصولات مرتبط" align="start" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-5">
{relatedProducts.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
)}
</main>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { getCategories, getProducts, safe } from "@/lib/api";
import { getMegaMenuColumns } from "@/lib/categoryTree";
import ProductCard from "@/components/ui/ProductCard";
import Pagination from "@/components/ui/Pagination";
const PAGE_SIZE = 12;
export const metadata = {
title: "محصولات | کودکانه",
};
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; page?: string }>;
}) {
const params = await searchParams;
const page = Math.max(1, Number(params.page) || 1);
const categoriesFlat = await safe(getCategories(), []);
const categoryColumns = getMegaMenuColumns(categoriesFlat);
const filterItems = categoryColumns.flatMap((col) => col.children);
const activeCategory = params.category
? categoriesFlat.find((c) => c.slug === params.category)
: undefined;
const result = await safe(
getProducts({ page, pageSize: PAGE_SIZE, categoryId: activeCategory?.id }),
{ items: [], total: 0, page, pageSize: PAGE_SIZE }
);
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
function buildHref(targetPage: number) {
const qs = new URLSearchParams();
if (params.category) qs.set("category", params.category);
if (targetPage > 1) qs.set("page", String(targetPage));
const query = qs.toString();
return `/products${query ? `?${query}` : ""}`;
}
return (
<main className="boxed py-8 md:py-12">
<div className="mb-8">
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
Products
</span>
<h1 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
{activeCategory ? activeCategory.nameFa : "همه محصولات"}
</h1>
</div>
<div className="flex flex-wrap gap-2 mb-8">
<Link
href="/products"
className={`rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold transition-colors ${
!activeCategory
? "bg-brand text-white"
: "border border-line text-ink hover:border-brand hover:text-brand"
}`}
>
همه
</Link>
{filterItems.map((cat) => (
<Link
key={cat.id}
href={`/products?category=${cat.slug}`}
className={`rounded-full px-4 h-9 flex items-center text-xs md:text-sm font-bold transition-colors ${
activeCategory?.id === cat.id
? "bg-brand text-white"
: "border border-line text-ink hover:border-brand hover:text-brand"
}`}
>
{cat.nameFa}
</Link>
))}
</div>
{result.items.length === 0 ? (
<p className="py-16 text-center text-body">محصولی یافت نشد.</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-5">
{result.items.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination page={page} totalPages={totalPages} buildHref={buildHref} />
</>
)}
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { StaticImageSlot } from "@/lib/types";
import { ShieldIcon, TagIcon, TruckIcon } from "./icons";
const points = [
{
icon: ShieldIcon,
title: "ضمانت اصالت کالا",
desc: "تمامی محصولات پیش از ارسال از نظر کیفیت و اصالت بررسی می‌شوند.",
},
{
icon: TruckIcon,
title: "ارسال سریع و رایگان",
desc: "ارسال به سراسر کشور با پیک و پست پیشتاز، رایگان برای خریدهای بالای سقف تعیین‌شده.",
},
{
icon: TagIcon,
title: "قیمت مناسب و عمده",
desc: "خرید مستقیم از تولیدی با قیمت رقابتی برای مشتریان خرد و عمده.",
},
];
export default function AboutUs({ slot }: { slot?: StaticImageSlot }) {
const img = slot?.images[0];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const src = img?.url ?? "/images/about/about.svg";
return (
<section id="about" className="boxed py-10 md:py-14">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-14 items-center">
<div className="overflow-hidden rounded-lg border border-line order-2 lg:order-1">
<img
src={src}
alt=""
style={aspectStyle}
className={`w-full object-cover ${aspectStyle ? "" : "aspect-[9/8]"}`}
/>
</div>
<div className="order-1 lg:order-2">
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
About Us
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
درباره ما
</h2>
<span className="mt-3 block h-[3px] w-14 bg-brand rounded-full" />
<p className="mt-5 text-sm md:text-[15px] leading-7 md:leading-8 text-body">
فروشگاه اینترنتی کودکانه، تولیدکننده و عرضه‌کننده پوشاک بچگانه با
جدیدترین طرح‌ها برای نوزادان، دختران و پسران است. تمام محصولات با
پارچه‌های نرم و مناسب پوست حساس کودکان تولید می‌شوند و پیش از
انتشار در سایت، از نظر کیفیت دوخت و اصالت بررسی می‌شوند تا خیالتان
برای انتخاب لباس فرزندتان راحت باشد.
</p>
<div className="mt-7 space-y-5">
{points.map((p) => (
<div key={p.title} className="flex items-start gap-3.5">
<span className="flex items-center justify-center size-11 rounded-lg bg-brand-light text-brand shrink-0">
<p.icon className="size-5" />
</span>
<div>
<h3 className="text-sm md:text-[15px] font-bold text-ink">
{p.title}
</h3>
<p className="mt-1 text-xs md:text-sm text-body leading-6">
{p.desc}
</p>
</div>
</div>
))}
</div>
<a
href="#contact"
className="mt-8 inline-flex items-center rounded-md bg-brand px-6 py-3 text-sm font-bold text-white hover:bg-brand-dark transition-colors"
>
اطلاعات بیشتر
</a>
</div>
</div>
</section>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import Link from "next/link";
import "swiper/css";
import type { ApiBlog } from "@/lib/types";
import SectionHeading from "./ui/SectionHeading";
export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) {
if (posts.length === 0) return null;
return (
<section className="bg-surface py-10 md:py-14">
<div className="boxed">
<SectionHeading kicker="Our Blog" title="جدیدترین مقالات وبلاگ" />
<Swiper
spaceBetween={20}
slidesPerView={1.15}
breakpoints={{
640: { slidesPerView: 2, spaceBetween: 20 },
1024: { slidesPerView: 3, spaceBetween: 24 },
}}
>
{posts.map((post) => {
const date = post.publishedAt || post.createdAt;
return (
<SwiperSlide key={post.id}>
<Link
href={`/blog/${post.slug}`}
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
>
<img
src={post.thumbnailUrl || post.image || "/images/blog/post-1.svg"}
alt=""
className="w-full aspect-[4/3] object-cover"
/>
<div className="flex flex-1 flex-col p-4">
{date && (
<span className="font-en text-[11px] tracking-wider text-gold-dark font-semibold">
{date.slice(0, 10)}
</span>
)}
<h3 className="mt-2 text-sm md:text-[15px] font-bold text-ink leading-6 line-clamp-2 group-hover:text-brand transition-colors">
{post.title || post.nameFa}
</h3>
{post.summary && (
<p className="mt-2 text-xs md:text-[13px] leading-6 text-body line-clamp-2">
{post.summary}
</p>
)}
</div>
</Link>
</SwiperSlide>
);
})}
</Swiper>
</div>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import Link from "next/link";
import type { CategoryNode } from "@/lib/types";
import SectionHeading from "./ui/SectionHeading";
const PLACEHOLDER_IMAGES = [
"/images/categories/girls.svg",
"/images/categories/boys.svg",
"/images/categories/baby.svg",
"/images/categories/party.svg",
"/images/categories/sets.svg",
"/images/categories/outerwear.svg",
];
export default function Categories({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const items = categoryColumns.flatMap((col) => col.children);
if (items.length === 0) return null;
return (
<section className="py-10 md:py-14">
<div className="boxed">
<SectionHeading kicker="Categories" title="دسته‌بندی محصولات" />
<div className="bg-gold rounded-2xl md:rounded-3xl py-8 px-4 md:px-8">
<Swiper
spaceBetween={10}
slidesPerView={4}
breakpoints={{
480: { slidesPerView: 5 },
640: { slidesPerView: 6 },
1024: { slidesPerView: 9, spaceBetween: 14 },
}}
>
{items.map((cat, i) => (
<SwiperSlide key={cat.id}>
<Link href={`/products?category=${cat.slug}`} className="group block text-center">
<div className="overflow-hidden rounded-lg bg-white">
<img
src={PLACEHOLDER_IMAGES[i % PLACEHOLDER_IMAGES.length]}
alt={cat.nameFa}
className="w-full aspect-square object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<span className="mt-2 block text-xs md:text-sm font-bold text-ink">
{cat.nameFa}
</span>
</Link>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import Link from "next/link";
import Logo from "./Logo";
import { headerNavLinks, siteInfo } from "@/data/site";
import type { CategoryNode } from "@/lib/types";
import {
InstagramIcon,
MailIcon,
PhoneIcon,
PinIcon,
TelegramIcon,
WhatsappIcon,
} from "./icons";
export default function Footer({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const categoryItems = categoryColumns.flatMap((col) => col.children).slice(0, 6);
return (
<footer id="contact" className="bg-brand text-white/70 mt-auto">
<div className="boxed py-12 md:py-16 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10">
<div>
<Logo dark />
<p className="mt-4 text-sm leading-7 text-white/55">
مرجع تخصصی پوشاک بچگانه؛ لباس‌های شاد و باکیفیت برای نوزادان،
دختران و پسران با ارسال به سراسر کشور.
</p>
<div className="mt-5 flex items-center gap-3">
<a
href="#"
aria-label="اینستاگرام"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<InstagramIcon className="size-4" />
</a>
<a
href="#"
aria-label="تلگرام"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<TelegramIcon className="size-4" />
</a>
<a
href="#"
aria-label="واتساپ"
className="flex items-center justify-center size-9 rounded-full border border-white/15 hover:border-gold hover:text-gold transition-colors"
>
<WhatsappIcon className="size-4" />
</a>
</div>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">دسترسی سریع</h3>
<ul className="space-y-2.5 text-sm">
{headerNavLinks.map((link) => (
<li key={link.title}>
<a href={link.href} className="hover:text-gold transition-colors">
{link.title}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">دسته‌بندی‌ها</h3>
<ul className="space-y-2.5 text-sm">
{categoryItems.map((cat) => (
<li key={cat.id}>
<Link href={`/products?category=${cat.slug}`} className="hover:text-gold transition-colors">
{cat.nameFa}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-white font-bold text-sm mb-4">ارتباط با ما</h3>
<ul className="space-y-3.5 text-sm">
<li className="flex items-start gap-2.5">
<PhoneIcon className="size-4 mt-0.5 text-gold shrink-0" />
<span dir="ltr">{siteInfo.phone}</span>
</li>
<li className="flex items-start gap-2.5">
<PinIcon className="size-4 mt-0.5 text-gold shrink-0" />
<span>{siteInfo.address}</span>
</li>
</ul>
</div>
</div>
<div className="border-t border-white/10">
<div className="boxed py-5 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-white/45">
<span>
© {new Date().getFullYear()} تمامی حقوق برای فروشگاه {siteInfo.name} محفوظ است.
</span>
<span className="font-en tracking-wider">KIDONE STORE</span>
</div>
</div>
<div className="bg-brand-dark">
<div dir="ltr" className="boxed py-3 flex items-center justify-center gap-2 text-white/55 text-xs">
<MailIcon className="size-4 shrink-0" />
<span>طراحی و توسعه توسط تیم تجارت الکترونیک مشکی</span>
</div>
</div>
</footer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { StaticImageSlot } from "@/lib/types";
export default function FullBanner({ slot }: { slot?: StaticImageSlot }) {
const img = slot?.images[0];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const src = img?.url ?? "/images/banners/full-banner.svg";
const href = img?.linkUrl ?? "#";
return (
<section className="boxed py-2 md:py-4">
<a href={href} className="block overflow-hidden rounded-lg border border-line">
<img
src={src}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/5] md:aspect-[16/4]"
}`}
/>
</a>
</section>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Logo from "./Logo";
import SearchBox from "./SearchBox";
import {
BagIcon,
ChevronDownIcon,
CloseIcon,
InstagramIcon,
MenuIcon,
TelegramIcon,
WhatsappIcon,
} from "./icons";
import { headerNavLinks } from "@/data/site";
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
import type { CategoryNode } from "@/lib/types";
const socialLinks = [
{ icon: InstagramIcon, label: "اینستاگرام" },
{ icon: TelegramIcon, label: "تلگرام" },
{ icon: WhatsappIcon, label: "واتساپ" },
];
export default function Header({ categoryColumns }: { categoryColumns: CategoryNode[] }) {
const [productsOpen, setProductsOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileCategoriesOpen, setMobileCategoriesOpen] = useState(false);
return (
<header className="sticky top-0 z-50 bg-surface pt-2 md:pt-3 pb-0">
<div className="boxed">
<div className="rounded-2xl shadow-sm border border-line/60">
{/* Row 1: logo / search / actions */}
<div className="bg-white rounded-2xl lg:rounded-t-2xl lg:rounded-b-none flex h-16 md:h-20 items-center gap-3 md:gap-6 px-3 md:px-6">
<button
type="button"
className="lg:hidden p-1.5 text-ink shrink-0"
aria-label="باز کردن منو"
onClick={() => setMobileOpen(true)}
>
<MenuIcon className="size-6" />
</button>
<Logo />
<div className="hidden md:flex flex-1 items-center">
<SearchBox />
</div>
<div className="flex items-center gap-2 md:gap-3 ms-auto md:ms-0">
<a
href="#"
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
aria-label="سبد خرید"
>
<BagIcon className="size-5" />
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
0
</span>
</a>
<a
href={CUSTOMER_PORTAL_URL}
className="hidden sm:flex items-center gap-1.5 rounded-full bg-brand text-white text-xs md:text-sm font-bold px-4 md:px-5 h-10 md:h-11 hover:bg-brand-dark transition-colors whitespace-nowrap"
>
ورود <span className="text-white/50">|</span> ثبت‌نام
</a>
</div>
</div>
{/* Row 2: nav + socials */}
<div className="hidden lg:flex bg-brand rounded-b-2xl items-center justify-between h-12 px-4 md:px-6">
<nav className="flex items-center h-full">
<div
className="relative h-full flex items-center"
onMouseEnter={() => setProductsOpen(true)}
onMouseLeave={() => setProductsOpen(false)}
>
<button
type="button"
className="flex h-full items-center gap-1.5 px-4 text-sm font-medium text-white/90 hover:text-gold transition-colors"
>
محصولات
<ChevronDownIcon className="size-4" />
</button>
{productsOpen && categoryColumns.length > 0 && (
<div className="absolute top-full start-0 min-w-[420px] bg-white border border-line shadow-lg p-6 flex flex-wrap gap-8 z-50">
{categoryColumns.map((col) => (
<div key={col.id} className="min-w-[180px]">
<Link
href={`/products?category=${col.slug}`}
className="block font-bold text-ink text-sm mb-2.5 hover:text-brand transition-colors"
>
{col.nameFa}
</Link>
<ul className="space-y-1.5">
{col.children.map((sub) => (
<li key={sub.id}>
<Link
href={`/products?category=${sub.slug}`}
className="text-[13px] text-body hover:text-gold-dark transition-colors"
>
{sub.nameFa}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
{headerNavLinks.map((link) => (
<a
key={link.title}
href={link.href}
className="flex h-full items-center px-4 text-sm font-medium text-white/90 hover:text-gold transition-colors"
>
{link.title}
</a>
))}
</nav>
<div className="flex items-center gap-2">
{socialLinks.map(({ icon: Icon, label }) => (
<a
key={label}
href="#"
aria-label={label}
className="flex items-center justify-center size-8 rounded-md bg-white/10 text-white hover:bg-white/20 transition-colors"
>
<Icon className="size-4" />
</a>
))}
</div>
</div>
</div>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-ink/50"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute inset-y-0 start-0 w-[82%] max-w-xs bg-white flex flex-col">
<div className="flex items-center justify-between h-16 px-4 border-b border-line">
<Logo />
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="بستن منو"
className="p-2 text-ink"
>
<CloseIcon className="size-5" />
</button>
</div>
<div className="p-4 overflow-y-auto">
<div className="mb-4">
<SearchBox placeholder="جستجو..." inputHeightClassName="h-10" />
</div>
<button
type="button"
className="flex w-full items-center justify-between py-3 text-sm font-bold text-ink border-b border-line"
onClick={() => setMobileCategoriesOpen((v) => !v)}
>
محصولات
<ChevronDownIcon
className={`size-4 transition-transform ${
mobileCategoriesOpen ? "rotate-180" : ""
}`}
/>
</button>
{mobileCategoriesOpen && (
<div className="py-2 ps-3 space-y-3 border-b border-line">
{categoryColumns.map((col) => (
<div key={col.id}>
<Link
href={`/products?category=${col.slug}`}
className="block text-sm font-bold text-ink"
onClick={() => setMobileOpen(false)}
>
{col.nameFa}
</Link>
<ul className="mt-1.5 ps-3 space-y-1.5">
{col.children.map((sub) => (
<li key={sub.id}>
<Link
href={`/products?category=${sub.slug}`}
className="text-sm text-body"
onClick={() => setMobileOpen(false)}
>
{sub.nameFa}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
{headerNavLinks.map((link) => (
<a
key={link.title}
href={link.href}
className="block py-3 text-sm font-bold text-ink border-b border-line"
>
{link.title}
</a>
))}
<a
href={CUSTOMER_PORTAL_URL}
className="mt-5 flex items-center justify-center rounded-full bg-brand text-white text-sm font-bold h-11 hover:bg-brand-dark transition-colors"
>
ورود / ثبت‌نام
</a>
<div className="mt-5 flex items-center justify-center gap-2">
{socialLinks.map(({ icon: Icon, label }) => (
<a
key={label}
href="#"
aria-label={label}
className="flex items-center justify-center size-9 rounded-md border border-line text-ink"
>
<Icon className="size-4" />
</a>
))}
</div>
</div>
</div>
</div>
)}
</header>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Pagination } from "swiper/modules";
import "swiper/css";
import "swiper/css/pagination";
import type { StaticImageSlot } from "@/lib/types";
const fallbackSlides = [
"/images/hero/slide-1.svg",
"/images/hero/slide-2.svg",
"/images/hero/slide-3.svg",
];
export default function HeroSlider({ slot }: { slot?: StaticImageSlot }) {
const slides = slot?.images ?? [];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
return (
<section className="boxed pt-4 md:pt-6">
<Swiper
modules={[Autoplay, Pagination]}
autoplay={{ delay: 4500, disableOnInteraction: false }}
pagination={{ clickable: true }}
loop
className="hero-swiper rounded-lg overflow-hidden"
>
{slides.length > 0
? slides.map((img) => (
<SwiperSlide key={img.id}>
{img.linkUrl ? (
<a href={img.linkUrl}>
<img
src={img.url}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/6] md:aspect-[32/9]"
}`}
/>
</a>
) : (
<img
src={img.url}
alt=""
style={aspectStyle}
className={`w-full object-cover ${
aspectStyle ? "" : "aspect-[16/6] md:aspect-[32/9]"
}`}
/>
)}
</SwiperSlide>
))
: fallbackSlides.map((src) => (
<SwiperSlide key={src}>
<img
src={src}
alt=""
className="w-full aspect-[16/6] md:aspect-[32/9] object-cover"
/>
</SwiperSlide>
))}
</Swiper>
</section>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { siteInfo } from "@/data/site";
export default function Logo({ dark = false }: { dark?: boolean }) {
return (
<Link href="/" className="flex items-center shrink-0" aria-label={siteInfo.name}>
<img
src="/images/logo.png"
alt={siteInfo.name}
className={`h-9 md:h-10 w-auto object-contain ${
dark ? "bg-white rounded-md px-2 py-1" : ""
}`}
/>
</Link>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import type { ApiProduct, StoreItem } from "@/lib/types";
import { BagIcon, CheckIcon, PhoneIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
const trustPoints = [
{ icon: ShieldIcon, title: "استفاده از بهترین متریال" },
{ icon: TruckIcon, title: "ارسال سریع به سراسر کشور" },
{ icon: PhoneIcon, title: "مشاوره و پشتیبانی آنلاین" },
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی کالا" },
];
export default function ProductBuyPanel({
product,
items,
}: {
product: ApiProduct;
items: StoreItem[];
}) {
const [selectedId, setSelectedId] = useState(items[0]?.id);
const [added, setAdded] = useState(false);
const selected = items.find((i) => i.id === selectedId) ?? items[0];
const title = product.nameFa || product.title;
const variationName = selected?.selections[0]?.variationName;
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
const stockQuantity = selected
? selected.stockQuantity
: items.reduce((sum, i) => sum + i.stockQuantity, 0);
let finalPrice: number | null = product.store.minPrice;
let oldPrice: number | null = null;
let discountPercent: number | null = null;
if (selected) {
const { price, discountedPrice } = selected;
if (discountedPrice != null && price != null && discountedPrice < price) {
finalPrice = discountedPrice;
oldPrice = price;
discountPercent = Math.round(((price - discountedPrice) / price) * 100);
} else {
finalPrice = discountedPrice ?? price ?? product.store.minPrice;
}
}
function handleAddToCart() {
setAdded(true);
setTimeout(() => setAdded(false), 1800);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8 md:gap-10">
{/* Info column */}
<div>
<h1 className="text-xl md:text-2xl font-extrabold text-ink">{title}</h1>
{product.brand && <p className="mt-1.5 text-sm text-body">برند: {product.brand}</p>}
{product.summary && (
<div className="mt-5">
<p className="text-sm font-bold text-ink mb-1.5">توضیح کوتاه:</p>
<p className="text-sm text-body leading-7">{product.summary}</p>
</div>
)}
{items.length > 0 && (
<div className="mt-5">
<p className="text-sm font-bold text-ink mb-2.5">{variationName || "انتخاب گزینه"}</p>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const rawValue = item.selections[0]?.value;
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
disabled={item.stockQuantity <= 0}
title={item.label}
className={`flex items-center justify-center transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
colorHex
? `size-9 rounded-full border-2 ${
item.id === selectedId ? "border-brand" : "border-line"
}`
: `rounded-lg border px-4 h-10 text-sm font-bold ${
item.id === selectedId
? "border-brand bg-brand text-white"
: "border-line text-ink hover:border-brand hover:text-brand"
}`
}`}
>
{colorHex ? (
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
) : (
item.label
)}
</button>
);
})}
</div>
</div>
)}
<div className="mt-5 space-y-2 text-sm">
{items.length > 0 && (
<p className="text-body">
موجود در انبار:{" "}
<span className="font-bold text-ink">{stockQuantity.toLocaleString("en-US")} عدد</span>
</p>
)}
{product.categoryNameFa && (
<p className="text-body">
دسته‌بندی: <span className="font-bold text-ink">{product.categoryNameFa}</span>
</p>
)}
{product.tags.length > 0 && (
<p className="text-body">تگ‌ها: {product.tags.join("، ")}</p>
)}
</div>
</div>
{/* Purchase column */}
<div className="lg:sticky lg:top-24 space-y-4 h-fit">
<div className="rounded-2xl border border-line p-5 bg-white">
<div className="flex items-center justify-between pb-4 mb-4 border-b border-line">
<span
className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full ${
inStock ? "bg-brand-light text-brand" : "bg-ink/10 text-ink/50"
}`}
>
{inStock ? "آماده ارسال" : "ناموجود"}
</span>
<TruckIcon className="size-5 text-body" />
</div>
<div className="flex items-center justify-between text-sm text-body mb-1.5">
<span>قیمت:</span>
<div className="flex items-center gap-2">
{discountPercent != null && (
<span className="rounded-md bg-gold/20 text-gold-dark text-xs font-bold px-1.5 py-0.5">
%{discountPercent}
</span>
)}
{oldPrice != null && (
<span className="text-xs text-body/60 line-through">
{oldPrice.toLocaleString("en-US")}
</span>
)}
</div>
</div>
<div className="flex items-baseline justify-between mb-5">
<span className="text-xs text-body">تومان</span>
<span className="text-xl md:text-2xl font-extrabold text-ink">
{finalPrice != null ? finalPrice.toLocaleString("en-US") : "تماس بگیرید"}
</span>
</div>
<button
type="button"
onClick={handleAddToCart}
disabled={!inStock}
className={`w-full flex items-center justify-center gap-2 rounded-lg h-12 text-sm font-bold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
{added ? (
<>
<CheckIcon className="size-4.5" />
افزوده شد
</>
) : (
<>
<BagIcon className="size-4.5" />
افزودن به سبد خرید
</>
)}
</button>
</div>
<div className="rounded-2xl border border-dashed border-line p-5 grid grid-cols-2 gap-5">
{trustPoints.map((p) => (
<div key={p.title} className="flex flex-col items-center text-center gap-2">
<p.icon className="size-6 text-brand" />
<span className="text-xs text-body leading-5">{p.title}</span>
</div>
))}
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function ProductGallery({ images, title }: { images: string[]; title: string }) {
const gallery = images.length > 0 ? images : ["/images/products/product-1.svg"];
const [active, setActive] = useState(0);
function prev() {
setActive((i) => (i - 1 + gallery.length) % gallery.length);
}
function next() {
setActive((i) => (i + 1) % gallery.length);
}
return (
<div>
<div className="relative rounded-xl overflow-hidden border border-line bg-white">
<img src={gallery[active]} alt={title} className="w-full aspect-square object-cover" />
{gallery.length > 1 && (
<>
<button
type="button"
onClick={prev}
aria-label="تصویر قبلی"
className="absolute top-1/2 -translate-y-1/2 start-3 flex items-center justify-center size-9 rounded-full bg-white/90 border border-line text-ink hover:bg-white transition-colors"
>
<ChevronRightIcon className="size-4.5" />
</button>
<button
type="button"
onClick={next}
aria-label="تصویر بعدی"
className="absolute top-1/2 -translate-y-1/2 end-3 flex items-center justify-center size-9 rounded-full bg-white/90 border border-line text-ink hover:bg-white transition-colors"
>
<ChevronLeftIcon className="size-4.5" />
</button>
</>
)}
</div>
{gallery.length > 1 && (
<div className="mt-3 flex gap-2.5 overflow-x-auto">
{gallery.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setActive(i)}
className={`shrink-0 size-16 md:size-20 rounded-lg overflow-hidden border-2 transition-colors ${
i === active ? "border-brand" : "border-line"
}`}
>
<img src={src} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import type { EntityComment, TechnicalInfoValue } from "@/lib/types";
type Tab = "description" | "technical" | "reviews";
export default function ProductInfoTabs({
title,
descriptionHtml,
technicalValues,
comments,
}: {
title: string;
descriptionHtml?: string;
technicalValues: TechnicalInfoValue[];
comments: EntityComment[];
}) {
const [tab, setTab] = useState<Tab>("description");
const tabs: { key: Tab; label: string }[] = [
{ key: "description", label: "توضیحات" },
{ key: "technical", label: "مشخصات فنی" },
{ key: "reviews", label: `دیدگاه کاربران${comments.length > 0 ? ` (${comments.length})` : ""}` },
];
return (
<div className="mt-14 md:mt-20">
<div className="flex flex-wrap gap-2 border-b border-line pb-px">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`rounded-t-lg px-5 h-11 text-sm font-bold transition-colors border-b-2 -mb-px ${
tab === t.key
? "border-brand text-brand"
: "border-transparent text-body hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="pt-8">
{tab === "description" && (
<div>
<h2 className="text-lg md:text-xl font-extrabold text-ink mb-4">معرفی {title}</h2>
{descriptionHtml ? (
<div
className="rich-text text-body leading-8"
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
/>
) : (
<p className="text-sm text-body">توضیحی برای این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "technical" && (
<div>
{technicalValues.length > 0 ? (
<dl className="divide-y divide-line rounded-xl border border-line overflow-hidden">
{technicalValues.map((v, i) => (
<div key={i} className="flex justify-between gap-4 px-4 py-3 text-sm odd:bg-surface">
<dt className="text-body">{v.labelFa || v.label || v.key}</dt>
<dd className="font-bold text-ink">{v.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-body">مشخصات فنی این محصول ثبت نشده است.</p>
)}
</div>
)}
{tab === "reviews" && (
<div>
{comments.length > 0 ? (
<ul className="space-y-4">
{comments.map((c) => (
<li key={c.id} className="rounded-xl border border-line p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-ink">{c.authorName || "کاربر"}</span>
{c.createdAt && (
<span className="font-en text-xs text-body/60">{c.createdAt.slice(0, 10)}</span>
)}
</div>
{c.text && <p className="mt-2 text-sm text-body leading-7">{c.text}</p>}
</li>
))}
</ul>
) : (
<p className="text-sm text-body">
هنوز دیدگاهی برای این محصول ثبت نشده است.
</p>
)}
</div>
)}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { getProducts } from "@/lib/api";
import type { ApiProduct } from "@/lib/types";
import { formatToman } from "@/lib/format";
import { SearchIcon } from "./icons";
const MIN_QUERY_LENGTH = 3;
const DEBOUNCE_MS = 350;
export default function SearchBox({
placeholder = "جستجوی محصولات...",
inputHeightClassName = "h-11",
}: {
placeholder?: string;
inputHeightClassName?: string;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<ApiProduct[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
function handleQueryChange(value: string) {
setQuery(value);
if (value.trim().length < MIN_QUERY_LENGTH) {
setResults([]);
setOpen(false);
setLoading(false);
} else {
setLoading(true);
}
}
useEffect(() => {
const term = query.trim();
if (term.length < MIN_QUERY_LENGTH) return;
const handle = setTimeout(() => {
getProducts({ name: term, pageSize: 6 })
.then((res) => {
setResults(res.items);
setOpen(true);
})
.catch(() => setResults([]))
.finally(() => setLoading(false));
}, DEBOUNCE_MS);
return () => clearTimeout(handle);
}, [query]);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const term = query.trim();
const showDropdown = open && term.length >= MIN_QUERY_LENGTH;
return (
<div ref={containerRef} className="relative w-full">
<div
className={`flex w-full items-center gap-2 rounded-full border border-line bg-surface px-4 ${inputHeightClassName}`}
>
<SearchIcon className="size-4.5 text-body shrink-0" />
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onFocus={() => term.length >= MIN_QUERY_LENGTH && setOpen(true)}
placeholder={placeholder}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-body/70"
/>
</div>
{showDropdown && (
<div className="absolute top-full mt-2 start-0 w-full bg-white border border-line rounded-xl shadow-lg z-50 max-h-96 overflow-y-auto">
{loading && <p className="p-4 text-sm text-body text-center">در حال جستجو...</p>}
{!loading && results.length === 0 && (
<p className="p-4 text-sm text-body text-center">نتیجه‌ای یافت نشد</p>
)}
{!loading &&
results.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
onClick={() => setOpen(false)}
className="flex items-center gap-3 p-3 hover:bg-surface transition-colors border-b border-line last:border-0"
>
<img
src={product.thumbnailUrl || product.image || "/images/products/product-1.svg"}
alt=""
className="size-12 rounded-md object-cover shrink-0 bg-surface"
/>
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm text-ink line-clamp-1">
{product.nameFa || product.title}
</p>
<p className="mt-0.5 text-xs font-bold text-brand">
{product.store?.minPrice != null
? formatToman(product.store.minPrice)
: "تماس بگیرید"}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Navigation } from "swiper/modules";
import "swiper/css";
import type { ApiProduct } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function SpecialSaleCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
return (
<section id="special-sale" className="boxed py-10 md:py-14">
<div className="flex items-center justify-between mb-6 md:mb-8">
<div>
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
Special Discount
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">
تخفیف‌های ویژه
</h2>
</div>
<Link
href="/products"
className="flex items-center gap-1.5 rounded-full border border-brand text-brand text-xs md:text-sm font-bold px-4 md:px-5 h-9 md:h-10 hover:bg-brand hover:text-white transition-colors whitespace-nowrap"
>
مشاهده همه
<ChevronLeftIcon className="size-4 [stroke-width:2.2]" />
</Link>
</div>
<div className="relative bg-brand rounded-2xl md:rounded-3xl p-4 md:p-8">
<Swiper
modules={[Autoplay, Navigation]}
autoplay={{ delay: 3200, disableOnInteraction: false }}
navigation={{
prevEl: ".sale-prev",
nextEl: ".sale-next",
}}
loop={products.length > 5}
spaceBetween={14}
slidesPerView={2.15}
breakpoints={{
480: { slidesPerView: 2.4 },
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 5, spaceBetween: 20 },
}}
>
<SwiperSlide>
<img
src="/images/special-sale/banner.svg"
alt=""
className="w-full h-full aspect-[4/5] object-cover rounded-lg"
/>
</SwiperSlide>
{products.map((product) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
<button
type="button"
aria-label="اسلاید قبلی"
className="sale-prev hidden sm:flex absolute top-1/2 -translate-y-1/2 start-0 z-10 translate-x-1/2 items-center justify-center size-10 md:size-11 rounded-full bg-white text-brand shadow-md hover:bg-gold hover:text-ink transition-colors"
>
<ChevronRightIcon className="size-5" />
</button>
<button
type="button"
aria-label="اسلاید بعدی"
className="sale-next hidden sm:flex absolute top-1/2 -translate-y-1/2 end-0 z-10 -translate-x-1/2 items-center justify-center size-10 md:size-11 rounded-full bg-white text-brand shadow-md hover:bg-gold hover:text-ink transition-colors"
>
<ChevronLeftIcon className="size-5" />
</button>
</div>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import type { ApiProduct } from "@/lib/types";
import ProductCard from "./ui/ProductCard";
import SectionHeading from "./ui/SectionHeading";
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
export default function TopSellingCarousel({ products }: { products: ApiProduct[] }) {
if (products.length === 0) return null;
return (
<section className="boxed py-10 md:py-14">
<div className="flex items-end justify-between">
<SectionHeading kicker="Best Sellers" title="پرفروش‌ترین‌ها" align="start" />
<div className="hidden sm:flex items-center gap-2 mb-10">
<button
type="button"
className="top-selling-prev flex items-center justify-center size-9 rounded-full border border-line text-ink hover:bg-brand hover:border-brand hover:text-white transition-colors"
aria-label="قبلی"
>
<ChevronRightIcon className="size-4" />
</button>
<button
type="button"
className="top-selling-next flex items-center justify-center size-9 rounded-full border border-line text-ink hover:bg-brand hover:border-brand hover:text-white transition-colors"
aria-label="بعدی"
>
<ChevronLeftIcon className="size-4" />
</button>
</div>
</div>
<Swiper
modules={[Navigation]}
navigation={{
prevEl: ".top-selling-prev",
nextEl: ".top-selling-next",
}}
spaceBetween={16}
slidesPerView={2.2}
breakpoints={{
640: { slidesPerView: 3.2 },
1024: { slidesPerView: 4 },
1280: { slidesPerView: 5 },
}}
>
{products.map((product) => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { StaticImageSlot } from "@/lib/types";
const fallbackBanners = [
{ image: "/images/banners/banner-1.svg", href: "#" },
{ image: "/images/banners/banner-2.svg", href: "#" },
];
export default function TwoBanners({ slot }: { slot?: StaticImageSlot }) {
const images = slot?.images ?? [];
const aspectStyle = slot?.aspectRatio ? { aspectRatio: slot.aspectRatio } : undefined;
const banners =
images.length > 0
? images.map((img) => ({ image: img.url, href: img.linkUrl || undefined }))
: fallbackBanners;
return (
<section className="boxed py-10 md:py-14">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{banners.map((b, i) => {
const Wrapper = b.href ? "a" : "div";
return (
<Wrapper
key={i}
{...(b.href ? { href: b.href } : {})}
className="group block overflow-hidden rounded-lg border border-line"
>
<img
src={b.image}
alt=""
style={aspectStyle}
className={`w-full object-cover transition-transform duration-300 group-hover:scale-105 ${
aspectStyle ? "" : "aspect-[16/8]"
}`}
/>
</Wrapper>
);
})}
</div>
</section>
);
}
+173
View File
@@ -0,0 +1,173 @@
type IconProps = { className?: string };
const base = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function SearchIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
}
export function BagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 8h12l-1 12H7L6 8Z" />
<path d="M9 8V6a3 3 0 0 1 6 0v2" />
</svg>
);
}
export function ChevronDownIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 9l6 6 6-6" />
</svg>
);
}
export function ChevronLeftIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
export function ChevronRightIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M9 18l6-6-6-6" />
</svg>
);
}
export function MenuIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
}
export function CloseIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
export function PhoneIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M5 4h3l2 5-2.5 1.5a11 11 0 0 0 5 5L14 13l5 2v3a2 2 0 0 1-2 2C10.5 20 4 13.5 4 6a2 2 0 0 1 1-2Z" />
</svg>
);
}
export function TruckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M3 7h11v9H3z" />
<path d="M14 10h4l3 3v3h-7z" />
<circle cx="7" cy="18" r="1.6" />
<circle cx="17.5" cy="18" r="1.6" />
</svg>
);
}
export function ShieldIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3Z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
export function TagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M11 3h6a2 2 0 0 1 2 2v6l-9 9-8-8 9-9Z" />
<circle cx="15.5" cy="8.5" r="1.2" />
</svg>
);
}
export function InstagramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17" cy="7" r="0.6" fill="currentColor" stroke="none" />
</svg>
);
}
export function TelegramIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.446 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.334-.373-.121l-6.869 4.326-2.96-.924c-.643-.204-.658-.643.136-.953l11.57-4.461c.538-.196 1.006.128.828.941z" />
</svg>
);
}
export function WhatsappIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none">
<path d="M12.04 2.003c-5.5 0-9.96 4.46-9.96 9.96 0 1.76.46 3.48 1.34 4.99L2 22l5.2-1.36a9.94 9.94 0 0 0 4.84 1.24c5.5 0 9.96-4.46 9.96-9.96 0-2.66-1.04-5.16-2.92-7.04a9.9 9.9 0 0 0-7.04-2.92zm0 1.8c2.15 0 4.17.84 5.7 2.36a8.03 8.03 0 0 1 2.36 5.7c0 4.44-3.62 8.06-8.07 8.06a8.05 8.05 0 0 1-4.1-1.12l-.29-.17-3.06.8.82-2.99-.19-.31a7.98 7.98 0 0 1-1.23-4.28c0-4.45 3.61-8.06 8.06-8.06zm-4.4 4.6c-.15 0-.39.05-.6.29-.2.23-.78.76-.78 1.85s.8 2.15.91 2.3c.11.15 1.56 2.47 3.83 3.37.53.22.95.35 1.28.44.54.15 1.03.13 1.42.08.43-.07 1.34-.55 1.53-1.08.19-.53.19-.99.13-1.08-.06-.1-.2-.15-.43-.27-.22-.11-1.34-.66-1.55-.74-.21-.08-.36-.11-.51.11-.15.22-.58.74-.71.89-.13.15-.26.16-.48.05-.22-.11-.95-.35-1.81-1.12-.67-.6-1.12-1.33-1.25-1.55-.13-.22-.01-.34.1-.45.1-.1.22-.26.33-.39.11-.13.15-.22.22-.37.07-.15.04-.28-.02-.39-.05-.11-.51-1.27-.71-1.74-.19-.45-.38-.39-.51-.4h-.44z" />
</svg>
);
}
export function PinIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M12 21s7-6.5 7-12a7 7 0 1 0-14 0c0 5.5 7 12 7 12Z" />
<circle cx="12" cy="9" r="2.3" />
</svg>
);
}
export function RotateIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 12a8 8 0 0 1 14-5.2M20 12a8 8 0 0 1-14 5.2" />
<path d="M18 3v4h-4M6 21v-4h4" />
</svg>
);
}
export function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
}
export function CommentIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 5h16v11H8l-4 4V5Z" />
</svg>
);
}
export function MailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="M4 6.5l8 6 8-6" />
</svg>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from "next/link";
import { ChevronLeftIcon, ChevronRightIcon } from "../icons";
export default function Pagination({
page,
totalPages,
buildHref,
}: {
page: number;
totalPages: number;
buildHref: (page: number) => string;
}) {
if (totalPages <= 1) return null;
const pages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(
(p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1
);
return (
<nav className="mt-10 flex items-center justify-center gap-1.5" aria-label="صفحه‌بندی">
<Link
href={buildHref(Math.max(1, page - 1))}
aria-disabled={page === 1}
className={`flex items-center justify-center size-9 rounded-full border border-line text-ink transition-colors ${
page === 1 ? "pointer-events-none opacity-40" : "hover:bg-brand hover:border-brand hover:text-white"
}`}
>
<ChevronRightIcon className="size-4" />
</Link>
{pages.map((p, i) => {
const prev = pages[i - 1];
const gap = prev !== undefined && p - prev > 1;
return (
<span key={p} className="flex items-center gap-1.5">
{gap && <span className="px-1 text-body">...</span>}
<Link
href={buildHref(p)}
className={`flex items-center justify-center size-9 rounded-full text-sm font-bold transition-colors ${
p === page
? "bg-brand text-white"
: "border border-line text-ink hover:bg-brand hover:border-brand hover:text-white"
}`}
>
{p}
</Link>
</span>
);
})}
<Link
href={buildHref(Math.min(totalPages, page + 1))}
aria-disabled={page === totalPages}
className={`flex items-center justify-center size-9 rounded-full border border-line text-ink transition-colors ${
page === totalPages
? "pointer-events-none opacity-40"
: "hover:bg-brand hover:border-brand hover:text-white"
}`}
>
<ChevronLeftIcon className="size-4" />
</Link>
</nav>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import type { ApiProduct } from "@/lib/types";
import { formatToman } from "@/lib/format";
export default function ProductCard({ product }: { product: ApiProduct }) {
const title = product.nameFa || product.title;
const image =
product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg";
const store = product.store;
return (
<Link
href={`/products/${product.slug}`}
className="group flex flex-col h-full bg-white border border-line rounded-lg overflow-hidden"
>
<div className="relative">
<img src={image} alt={title} className="w-full aspect-[4/5] object-cover" />
{store && !store.inStock && (
<span className="absolute inset-0 flex items-center justify-center bg-white/70">
<span className="rounded-full bg-ink/85 text-white text-xs font-medium px-3 py-1">
ناموجود
</span>
</span>
)}
</div>
<div className="flex flex-1 flex-col p-3.5">
<h3 className="text-[13px] md:text-sm text-ink line-clamp-2 min-h-[2.5em]">{title}</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm md:text-[15px] text-brand">
{store?.minPrice != null ? formatToman(store.minPrice) : "تماس بگیرید"}
</span>
{store?.maxPrice != null && store.maxPrice !== store.minPrice && (
<span className="text-xs text-body/60">تا {formatToman(store.maxPrice)}</span>
)}
</div>
</div>
</Link>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function SectionHeading({
kicker,
title,
align = "center",
}: {
kicker: string;
title: string;
align?: "center" | "start";
}) {
return (
<div className={`mb-8 md:mb-10 ${align === "center" ? "text-center" : "text-start"}`}>
<span className="font-en text-xs md:text-sm font-semibold tracking-[4px] text-gold-dark uppercase">
{kicker}
</span>
<h2 className="mt-1.5 text-xl md:text-3xl font-extrabold text-ink">{title}</h2>
<span
className={`mt-3 block h-[3px] w-14 bg-brand rounded-full ${
align === "center" ? "mx-auto" : ""
}`}
/>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export const siteInfo = {
name: "کودکانه",
nameEn: "KIDONE",
phone: "021-91234567",
address: "تهران، خیابان ولیعصر، مرکز پخش پوشاک",
email: "info@kidone.shop",
};
export const headerNavLinks = [
{ title: "تخفیف‌های ویژه", href: "#special-sale" },
{ title: "درباره ما", href: "#about" },
{ title: "سوالات متداول", href: "#faq" },
{ title: "تماس با ما", href: "#contact" },
];
+117
View File
@@ -0,0 +1,117 @@
import { API_BASE, SITE_DOMAIN } from "./config";
import type {
ApiBlog,
ApiCategory,
ApiProduct,
EntityComment,
PagedResult,
ProductVariation,
StaticImageSlot,
StoreItem,
TechnicalInfoValue,
} from "./types";
type QueryParams = Record<string, string | number | boolean | undefined>;
async function apiGet<T>(path: string, params?: QueryParams): Promise<T> {
const url = new URL(`${API_BASE}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
const res = await fetch(url.toString(), { next: { revalidate: 60 } });
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// Swallows errors so a backend hiccup degrades a section instead of
// crashing the whole page.
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
try {
return await promise;
} catch {
return fallback;
}
}
export function getCategories(
entityType: "product" | "blog" | "portfolio" | "video" = "product"
): Promise<ApiCategory[]> {
return apiGet<{ items: ApiCategory[] }>(`/tenants/${SITE_DOMAIN}/categories`, {
entityType,
}).then((r) => r.items);
}
export function getProducts(
params: {
page?: number;
pageSize?: number;
name?: string;
categoryId?: string;
brandId?: string;
tag?: string;
inStore?: boolean;
} = {}
): Promise<PagedResult<ApiProduct>> {
return apiGet<PagedResult<ApiProduct>>(`/tenants/${SITE_DOMAIN}/products`, params);
}
export function getProduct(
slug: string
): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}`);
}
export function getProductVariations(
slug: string
): Promise<{ variations: ProductVariation[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/variations`);
}
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/store-items`, { productId, pageSize: 100 });
}
export function getProductTechnicalInfo(
slug: string
): Promise<{ form: unknown; values: TechnicalInfoValue[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/technical-info`);
}
export function getComments(
entityType: "product" | "blog" | "portfolio" | "video",
entityId: string
): Promise<{ items: EntityComment[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/comments`, { entityType, entityId });
}
export function getBlogs(
params: { page?: number; pageSize?: number; categoryId?: string; title?: string } = {}
): Promise<PagedResult<ApiBlog>> {
return apiGet<PagedResult<ApiBlog>>(`/tenants/${SITE_DOMAIN}/blogs`, params);
}
export function getBlog(slug: string): Promise<{ blog: ApiBlog }> {
return apiGet(`/tenants/${SITE_DOMAIN}/blogs/${slug}`);
}
export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
return apiGet<{ items: StaticImageSlot[] }>(`/tenants/${SITE_DOMAIN}/website/static-images`, {
pageKey,
}).then((r) => r.items);
}
export function findSlot(
slots: StaticImageSlot[],
key: string
): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key);
if (!slot || slot.images.length === 0) return undefined;
return { ...slot, images: [...slot.images].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) };
}
+30
View File
@@ -0,0 +1,30 @@
import type { ApiCategory, CategoryNode } from "./types";
export function buildCategoryTree(flat: ApiCategory[]): CategoryNode[] {
const byId = new Map<string, CategoryNode>();
flat.forEach((c) => byId.set(c.id, { ...c, children: [] }));
const roots: CategoryNode[] = [];
flat.forEach((c) => {
const node = byId.get(c.id)!;
const parent = c.parentId ? byId.get(c.parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
});
const bySortOrder = (a: CategoryNode, b: CategoryNode) => a.sortOrder - b.sortOrder;
const sortRecursive = (nodes: CategoryNode[]) => {
nodes.sort(bySortOrder);
nodes.forEach((n) => sortRecursive(n.children));
};
sortRecursive(roots);
return roots;
}
// The header mega-menu shows level 2 as columns and level 3 as the links
// inside each column, skipping the level-1 root category entirely.
export function getMegaMenuColumns(flat: ApiCategory[]): CategoryNode[] {
const tree = buildCategoryTree(flat);
return tree.flatMap((root) => root.children);
}
+3
View File
@@ -0,0 +1,3 @@
export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "simode.shop";
export const API_BASE = "https://api.meshkee.com/api/v1";
export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
+3
View File
@@ -0,0 +1,3 @@
export function formatToman(value: number): string {
return `${value.toLocaleString("en-US")} تومان`;
}
+166
View File
@@ -0,0 +1,166 @@
export interface StaticImageItem {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number;
}
export interface StaticImageSlot {
key: string;
pageKey: string;
kind: "single" | "list";
label: string;
aspectRatio?: string | number | null;
recommendedWidth?: number | null;
itemCount?: number | null;
images: StaticImageItem[];
}
export interface ApiCategory {
id: string;
businessId: string;
entityType: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
description: string | null;
sortOrder: number;
isActive: boolean;
variationCount: number;
createdAt: string;
updatedAt: string;
}
export interface CategoryNode extends ApiCategory {
children: CategoryNode[];
}
export interface ProductImage {
mediaId: string;
url: string;
}
export interface ProductStoreSummary {
variantCount: number;
minPrice: number | null;
maxPrice: number | null;
inStock: boolean;
}
export interface ApiProduct {
id: string;
businessId: string;
title: string;
nameFa: string;
summary: string;
descriptionHtml: string;
slug: string;
status: string;
categoryId: string | null;
categoryName: string | null;
categoryNameFa: string | null;
brandId: string | null;
brand: string | null;
tags: string[];
thumbnailUrl: string | null;
image: string | null;
thumbnail: string | null;
images: ProductImage[];
commentCount: number;
variantCount: number;
createdAt: string;
updatedAt: string;
store: ProductStoreSummary;
}
export interface PagedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// Blog object shape isn't documented and this tenant has no posts yet, so
// every field is optional and consumers must read defensively.
export interface ApiBlog {
id: string;
title?: string;
nameFa?: string;
summary?: string;
descriptionHtml?: string;
contentHtml?: string;
slug: string;
thumbnailUrl?: string | null;
image?: string | null;
images?: ProductImage[];
categoryName?: string | null;
categoryNameFa?: string | null;
createdAt?: string;
updatedAt?: string;
publishedAt?: string;
}
export interface VariationOption {
id: string;
label: string;
value: string;
colorHex: string | null;
sortOrder: number;
}
export interface ProductVariation {
id: string;
name: string;
type: string;
sortOrder: number;
options: VariationOption[];
}
export interface StoreItemSelection {
variationId: string;
variationName: string;
optionId: string;
value: string;
}
export interface StoreItem {
id: string;
storeItemId: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
productTotalStock: number;
selections: StoreItemSelection[];
label: string;
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
rewardPoints: number | null;
isFestival: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
// Technical-info and comment shapes aren't documented and this tenant has
// no data yet — every field is optional and consumers must read defensively.
export interface TechnicalInfoValue {
key?: string;
label?: string;
labelFa?: string;
value?: string;
}
export interface EntityComment {
id: string;
authorName?: string;
text?: string;
createdAt?: string;
}