create
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "simode-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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/");
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export function formatToman(value: number): string {
|
||||
return `${value.toLocaleString("en-US")} تومان`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||