feat: product page layout toggle (flat/card), fix email shortcode rendering
- Add layout_style setting (flat default) to product appearance
- AppearanceController: sanitize & persist layout_style, add to default settings
- Admin SPA: Layout Style select in Appearance > Product
- Customer SPA: useEffect targets <main> bg-white in flat mode (full-width),
card mode uses per-section white floating cards on gray background
- Accordion sections styled per mode: flat=border-t dividers, card=white cards
- Fix email shortcode gaps (EmailRenderer, EmailManager)
- Add missing variables: return_url, contact_url, account_url (alias),
payment_error_reason, order_items_list (alias for order_items_table)
- Fix customer_note extra_data key mismatch (note → customer_note)
- Pass low_stock_threshold via extra_data in low_stock email send
This commit is contained in:
100
customer-spa/package-lock.json
generated
100
customer-spa/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -62,7 +63,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -1057,7 +1057,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -1079,7 +1078,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1089,14 +1087,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1107,7 +1103,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -1121,7 +1116,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -1131,7 +1125,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -2660,6 +2653,31 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz",
|
||||
@@ -3099,14 +3117,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -3120,7 +3136,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -3365,7 +3380,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3388,7 +3402,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -3495,7 +3508,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3543,7 +3555,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -3568,7 +3579,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -3638,7 +3648,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3686,7 +3695,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -3827,14 +3835,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
@@ -4433,7 +4439,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -4450,7 +4455,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -4477,7 +4481,6 @@
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -4500,7 +4503,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -4581,7 +4583,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -4596,7 +4597,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4723,7 +4723,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -4867,7 +4866,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -5012,7 +5010,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -5055,7 +5052,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -5106,7 +5102,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5152,7 +5147,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -5191,7 +5185,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -5395,7 +5388,6 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -5511,7 +5503,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -5524,7 +5515,6 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -5595,7 +5585,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -5605,7 +5594,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -5642,7 +5630,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -5654,7 +5641,6 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5687,7 +5673,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5707,7 +5692,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5717,7 +5701,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5926,21 +5909,18 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -5953,7 +5933,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5963,7 +5942,6 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5983,7 +5961,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6012,7 +5989,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -6030,7 +6006,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -6051,7 +6026,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6077,7 +6051,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6120,7 +6093,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6146,7 +6118,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -6160,7 +6131,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
@@ -6199,7 +6169,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6405,7 +6374,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -6415,7 +6383,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -6500,7 +6467,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -6553,7 +6519,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6824,7 +6789,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6959,7 +6923,6 @@
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -6995,7 +6958,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -7018,7 +6980,6 @@
|
||||
"version": "3.4.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -7066,7 +7027,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -7087,7 +7047,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -7097,7 +7056,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -7110,7 +7068,6 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -7127,7 +7084,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -7145,7 +7101,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -7158,7 +7113,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -7184,7 +7138,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@@ -7421,7 +7374,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -20,6 +20,7 @@ import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import OrderPay from './pages/OrderPay';
|
||||
import Subscribe from './pages/Subscribe';
|
||||
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||
|
||||
// Create QueryClient instance
|
||||
@@ -116,6 +117,9 @@ function AppRoutes() {
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
|
||||
{/* Newsletter / Notifications */}
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
|
||||
{/* Login & Auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
|
||||
@@ -12,7 +12,7 @@ interface SharedContentProps {
|
||||
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
// Layout
|
||||
containerWidth?: 'full' | 'contained';
|
||||
containerWidth?: 'full' | 'contained' | 'boxed';
|
||||
|
||||
// Styles
|
||||
className?: string;
|
||||
@@ -53,15 +53,19 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
const isImageTop = imagePosition === 'top';
|
||||
const isImageBottom = imagePosition === 'bottom';
|
||||
|
||||
// Wrapper classes
|
||||
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-7xl' : ''
|
||||
containerWidth === 'contained' ? 'max-w-4xl'
|
||||
: containerWidth === 'boxed' ? 'max-w-5xl'
|
||||
: '' // full = no max-width cap
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto',
|
||||
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
|
||||
hasImage && (isImageLeft || isImageRight)
|
||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
|
||||
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
|
||||
);
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
@@ -74,82 +78,161 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{containerWidth === 'boxed' ? (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
|
||||
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8'
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ export function useProductSettings() {
|
||||
image_position: 'left' as string,
|
||||
gallery_style: 'thumbnails' as string,
|
||||
sticky_add_to_cart: false,
|
||||
layout_style: 'flat' as string,
|
||||
},
|
||||
elements: {
|
||||
breadcrumbs: true,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SectionStyleResult {
|
||||
*/
|
||||
export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult {
|
||||
if (!styles) {
|
||||
return { style: {}, hasOverlay: false, overlayStyle: undefined };
|
||||
return { style: {}, hasOverlay: false, overlayOpacity: 0 };
|
||||
}
|
||||
|
||||
const bgType = styles.backgroundType || 'solid';
|
||||
@@ -56,3 +56,30 @@ export function getSectionBackground(styles?: Record<string, any>): SectionStyle
|
||||
|
||||
return { style, hasOverlay, overlayOpacity, backgroundImage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns inner container class names for the three content width modes:
|
||||
* - full: edge-to-edge, no max-width
|
||||
* - contained: centered max-w-6xl (matches Product page / SPA default)
|
||||
* - boxed: centered max-w-5xl, wrapped in a white rounded-2xl card (matches product accordion cards)
|
||||
*
|
||||
* For 'boxed', apply this to the inner container div; no extra wrapper needed.
|
||||
*/
|
||||
export function getContentWidthClasses(contentWidth?: string): string {
|
||||
switch (contentWidth) {
|
||||
case 'full':
|
||||
return 'w-full px-4 md:px-8';
|
||||
case 'boxed':
|
||||
return 'container mx-auto px-4 max-w-5xl';
|
||||
case 'contained':
|
||||
default:
|
||||
return 'container mx-auto px-4';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the section uses the boxed (card) layout.
|
||||
*/
|
||||
export function isBoxedLayout(contentWidth?: string): boolean {
|
||||
return contentWidth === 'boxed';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Section Components
|
||||
import { HeroSection } from './sections/HeroSection';
|
||||
@@ -121,14 +122,25 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
const navigate = useNavigate();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
// Use prop slug if provided, otherwise use param slug
|
||||
const effectiveSlug = propSlug || paramSlug;
|
||||
// Get page type from DOM (injected by TemplateOverride.php)
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
const dataPageType = appEl?.getAttribute('data-page');
|
||||
const dataCptType = appEl?.getAttribute('data-cpt-type'); // e.g. 'post', 'portfolio'
|
||||
const dataCptSlug = appEl?.getAttribute('data-cpt-slug'); // e.g. 'my-post-slug'
|
||||
|
||||
// Determine content type:
|
||||
// Priority: pathBase from router > data-cpt-type from DOM > fallback
|
||||
const contentType = pathBase
|
||||
? (pathBase === 'blog' ? 'post' : pathBase)
|
||||
: (dataPageType === 'cpt' && dataCptType ? dataCptType : undefined);
|
||||
|
||||
// Effective slug: prefer router param, then DOM cpt-slug
|
||||
const effectiveSlug = propSlug || paramSlug || (dataPageType === 'cpt' ? dataCptSlug : undefined) || '';
|
||||
|
||||
// Determine if this is a page or CPT content
|
||||
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
|
||||
const isStructuralPage = !pathBase || !!propSlug;
|
||||
const contentType = pathBase === 'blog' ? 'post' : pathBase;
|
||||
const contentSlug = effectiveSlug || '';
|
||||
const isStructuralPage = dataPageType === 'page' || dataPageType === 'shop' || contentType === undefined;
|
||||
|
||||
const contentSlug = effectiveSlug;
|
||||
|
||||
// Fetch page/content data
|
||||
const { data: pageData, isLoading, error } = useQuery<PageData>({
|
||||
@@ -138,11 +150,12 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
// Fetch structural page - api.get returns JSON directly
|
||||
const response = await api.get<PageData>(`/pages/${contentSlug}`);
|
||||
return response;
|
||||
} else {
|
||||
} else if (contentType) {
|
||||
// Fetch CPT content with template
|
||||
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
|
||||
return response;
|
||||
}
|
||||
throw new Error("Unable to determine content type");
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
@@ -175,6 +188,16 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p className="text-gray-600 mb-8">Page not found</p>
|
||||
<div className="bg-gray-100 p-4 rounded text-left mb-8 text-sm">
|
||||
<strong>DEBUG INFO:</strong><br />
|
||||
pathBase: {pathBase ?? 'undefined'}<br />
|
||||
propSlug: {propSlug ?? 'undefined'}<br />
|
||||
paramSlug: {paramSlug ?? 'undefined'}<br />
|
||||
effectiveSlug: {effectiveSlug ?? 'undefined'}<br />
|
||||
dataPageType: {dataPageType ?? 'undefined'}<br />
|
||||
contentType: {contentType ?? 'undefined'}<br />
|
||||
isStructuralPage: {isStructuralPage ? 'true' : 'false'}<br />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
@@ -226,15 +249,15 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
// Only explicit custom padding overrides from the padding fields
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
{/* Full-bleed background image & overlay */}
|
||||
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
@@ -247,11 +270,11 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
|
||||
{/* Section component — manages its own background, height, and inner content width */}
|
||||
<div className="relative z-10 w-full">
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section} // Pass full section object for components that need raw data
|
||||
section={section}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
id: string;
|
||||
@@ -22,26 +23,34 @@ export function CTABannerSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
color: es.color,
|
||||
textAlign: es.textAlign,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -49,6 +58,83 @@ export function CTABannerSection({
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
|
||||
// Shared inner content — same markup used in boxed and non-boxed
|
||||
const innerContent = (
|
||||
<>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-cta__title mb-6",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<p className={cn(
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||
styles?.contentWidth !== 'boxed' && {
|
||||
'text-white/90': colorScheme === 'primary',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
},
|
||||
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{button_text && button_url && (
|
||||
<a
|
||||
href={button_url}
|
||||
className={cn(
|
||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
|
||||
? 'bg-primary'
|
||||
: {
|
||||
'bg-white': colorScheme === 'primary',
|
||||
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
}),
|
||||
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
|
||||
? 'text-primary-foreground'
|
||||
: {
|
||||
'text-primary': colorScheme === 'primary',
|
||||
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
}),
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
>
|
||||
{button_text}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -56,70 +142,29 @@ export function CTABannerSection({
|
||||
'wn-section wn-cta-banner',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
heightClasses,
|
||||
{
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
||||
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-cta__title mb-6",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<p className={cn(
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||
{
|
||||
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
},
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{button_text && button_url && (
|
||||
<a
|
||||
href={button_url}
|
||||
className={cn(
|
||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && {
|
||||
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
!btnStyle.style?.color && {
|
||||
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
>
|
||||
{button_text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{styles?.contentWidth === 'boxed' ? (
|
||||
<div className="container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center">
|
||||
{innerContent}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{innerContent}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
id: string;
|
||||
@@ -23,6 +24,15 @@ export function ContactFormSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -87,6 +97,19 @@ export function ContactFormSection({
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,17 +118,18 @@ export function ContactFormSection({
|
||||
className={cn(
|
||||
'wn-section wn-contact-form',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
heightClasses,
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'max-w-xl mx-auto',
|
||||
@@ -116,7 +140,7 @@ export function ContactFormSection({
|
||||
{title && (
|
||||
<h2 className={cn(
|
||||
"wn-contact__title text-center mb-12",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ContentSectionProps {
|
||||
id?: string;
|
||||
section: {
|
||||
id: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props?: {
|
||||
content?: { value: string };
|
||||
cta_text?: { value: string };
|
||||
cta_url?: { value: string };
|
||||
};
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: Record<string, any>;
|
||||
props?: any;
|
||||
};
|
||||
content?: string;
|
||||
cta_text?: string;
|
||||
cta_url?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
@@ -25,7 +26,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
@@ -164,11 +164,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentSection({ section }: ContentSectionProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, outerPadding = false }: ContentSectionProps & { outerPadding?: boolean }) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
// Default to 'default' width if not specified
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = section.styles?.contentWidth === 'full' ? WIDTH_CLASSES.full : (WIDTH_CLASSES[layout] || WIDTH_CLASSES.default);
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
@@ -182,7 +181,7 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || '';
|
||||
const content = propContent || section.props?.content?.value || section.props?.content || '';
|
||||
|
||||
// Helper to get text styles
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -209,15 +208,16 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
const textStyle = getTextStyles('text');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const containerWidth = section.styles?.contentWidth || 'contained';
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
||||
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
|
||||
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(section.styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
@@ -234,9 +234,8 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
id={section.id}
|
||||
className={cn(
|
||||
'wn-content',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface FeatureItem {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
// Post-card fields (from related_posts dynamic source)
|
||||
url?: string;
|
||||
featured_image?: string;
|
||||
excerpt?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface FeatureGridSectionProps {
|
||||
@@ -26,15 +32,26 @@ export function FeatureGridSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||
// Use items or features (priority to items if both exist, but usually only one comes from props)
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
const listItems = items.length > 0 ? items : features;
|
||||
|
||||
const gridCols = {
|
||||
'grid-2': 'md:grid-cols-2',
|
||||
'grid-3': 'md:grid-cols-3',
|
||||
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}[layout] || 'md:grid-cols-3';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
// Detect if these are post-cards (from related_posts) — they have a url field
|
||||
const isPostCards = listItems.some(item => !!item.url);
|
||||
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
@@ -60,6 +77,21 @@ export function FeatureGridSection({
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -67,23 +99,25 @@ export function FeatureGridSection({
|
||||
'wn-section wn-feature-grid',
|
||||
`wn-feature-grid--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-24',
|
||||
heightClasses,
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-features__heading text-center mb-12",
|
||||
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
|
||||
"wn-features__heading text-center mb-10",
|
||||
!elementStyles?.heading?.fontSize && "text-2xl md:text-3xl lg:text-4xl",
|
||||
!elementStyles?.heading?.fontWeight && "font-bold",
|
||||
headingStyle.classNames
|
||||
)}
|
||||
@@ -93,59 +127,122 @@ export function FeatureGridSection({
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridCols)}>
|
||||
{listItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'wn-feature-grid__item',
|
||||
'p-6 rounded-xl',
|
||||
!featureItemStyle.style?.backgroundColor && {
|
||||
'bg-white shadow-lg': colorScheme !== 'primary',
|
||||
'bg-white/10': colorScheme === 'primary',
|
||||
},
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
{item.icon && (() => {
|
||||
const IconComponent = (LucideIcons as any)[item.icon];
|
||||
if (!IconComponent) return null;
|
||||
return (
|
||||
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
|
||||
<IconComponent className="w-8 h-8" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{item.title && (
|
||||
<h3
|
||||
<div className={cn('grid gap-6', gridCols)}>
|
||||
{listItems.map((item, index) => {
|
||||
// ── Post Card (from related_posts) ──────────────────────────
|
||||
if (isPostCards) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={item.url || '#'}
|
||||
className={cn(
|
||||
"wn-feature-grid__item-title mb-3",
|
||||
!featureItemStyle.classNames && "text-xl font-semibold"
|
||||
'wn-post-card group block rounded-xl overflow-hidden transition-all duration-200',
|
||||
'bg-white shadow-md hover:shadow-xl hover:-translate-y-1',
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
{/* Thumbnail */}
|
||||
{item.featured_image ? (
|
||||
<div className="aspect-[16/9] overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={item.featured_image}
|
||||
alt={item.title || ''}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[16/9] bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.description && (
|
||||
<p className={cn(
|
||||
'wn-feature-grid__item-desc',
|
||||
!featureItemStyle.style?.color && {
|
||||
'text-gray-600': colorScheme !== 'primary',
|
||||
'text-white/80': colorScheme === 'primary',
|
||||
}
|
||||
{/* Card Body */}
|
||||
<div className="p-5">
|
||||
{item.date && (
|
||||
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
|
||||
)}
|
||||
{item.title && (
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
{(item.excerpt || item.description) && (
|
||||
<p className="text-sm text-gray-500 line-clamp-3 mb-4">
|
||||
{item.excerpt || item.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Read more
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature Card (icon + title + desc) ─────────────────────
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'wn-feature-grid__item',
|
||||
'p-6 rounded-xl',
|
||||
!featureItemStyle.style?.backgroundColor && {
|
||||
'bg-white shadow-lg': colorScheme !== 'primary',
|
||||
'bg-white/10': colorScheme === 'primary',
|
||||
},
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
{item.icon && (() => {
|
||||
const IconComponent = (LucideIcons as any)[item.icon];
|
||||
if (!IconComponent) return null;
|
||||
return (
|
||||
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
|
||||
<IconComponent className="w-8 h-8" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{item.title && (
|
||||
<h3
|
||||
className={cn(
|
||||
"wn-feature-grid__item-title mb-3",
|
||||
!featureItemStyle.classNames && "text-xl font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn(
|
||||
'wn-feature-grid__item-desc',
|
||||
!featureItemStyle.style?.color && {
|
||||
'text-gray-600': colorScheme !== 'primary',
|
||||
'text-white/80': colorScheme === 'primary',
|
||||
}
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty state for related posts */}
|
||||
{isPostCards && listItems.length === 0 && (
|
||||
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,15 @@ export function HeroSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-16 md:py-28',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
|
||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||
const isCentered = layout === 'centered' || layout === 'default';
|
||||
@@ -67,9 +76,6 @@ export function HeroSection({
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
// If user set custom bg via Design tab, use that
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'gradient') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
@@ -79,7 +85,7 @@ export function HeroSection({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
|
||||
const isDynamicScheme = ['primary', 'secondary'].includes(colorScheme) && !hasCustomBackground;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -88,12 +94,15 @@ export function HeroSection({
|
||||
'wn-section wn-hero',
|
||||
`wn-hero--${layout}`,
|
||||
'relative overflow-hidden',
|
||||
heightClasses,
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<div className={cn(
|
||||
'mx-auto px-4 z-10 relative flex w-full',
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container max-w-7xl',
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container max-w-7xl',
|
||||
{
|
||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||
'text-center': isCentered,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ImageTextSectionProps {
|
||||
id: string;
|
||||
@@ -66,25 +67,40 @@ export function ImageTextSection({
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-image-text',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary/5': colorScheme === 'primary',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<SharedContentLayout
|
||||
title={title}
|
||||
text={text}
|
||||
image={image}
|
||||
imagePosition={isImageRight ? 'right' : 'left'}
|
||||
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
|
||||
containerWidth={styles?.contentWidth === 'full' ? 'full' : styles?.contentWidth === 'boxed' ? 'boxed' : 'contained'}
|
||||
titleStyle={titleStyle.style}
|
||||
titleClassName={titleStyle.classNames}
|
||||
textStyle={textStyle.style}
|
||||
|
||||
@@ -29,6 +29,20 @@ export default function Product() {
|
||||
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
|
||||
const { isEnabled: isModuleEnabled } = useModules();
|
||||
|
||||
// Apply white background to <main> in flat mode so the full viewport width is white
|
||||
useEffect(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main) return;
|
||||
if (layout.layout_style === 'flat') {
|
||||
(main as HTMLElement).style.backgroundColor = '#ffffff';
|
||||
} else {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
}
|
||||
return () => {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
};
|
||||
}, [layout.layout_style]);
|
||||
|
||||
// Fetch product details by slug
|
||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||
queryKey: ['product', slug],
|
||||
@@ -94,10 +108,16 @@ export default function Product() {
|
||||
// Find matching variation when attributes change
|
||||
useEffect(() => {
|
||||
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
||||
const variation = (product.variations as any[]).find(v => {
|
||||
if (!v.attributes) return false;
|
||||
let bestMatch: any = null;
|
||||
let highestScore = -1;
|
||||
|
||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
(product.variations as any[]).forEach(v => {
|
||||
if (!v.attributes) return;
|
||||
|
||||
let isMatch = true;
|
||||
let score = 0;
|
||||
|
||||
const attributesMatch = Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
const normalizedSelectedValue = attrValue.toLowerCase().trim();
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
@@ -108,17 +128,11 @@ export default function Product() {
|
||||
// Try to find a matching key in the variation attributes
|
||||
let variationValue: string | undefined = undefined;
|
||||
|
||||
// Check for common WooCommerce attribute key formats
|
||||
// 1. Check strict slug format (attribute_7-days-...)
|
||||
if (`attribute_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrSlug}`];
|
||||
}
|
||||
// 2. Check pa_ format (attribute_pa_color)
|
||||
else if (`attribute_pa_${attrSlug}` in v.attributes) {
|
||||
} else if (`attribute_pa_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
|
||||
}
|
||||
// 3. Fallback to name-based checks (legacy)
|
||||
else if (`attribute_${attrNameLower}` in v.attributes) {
|
||||
} else if (`attribute_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrNameLower}`];
|
||||
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
|
||||
@@ -126,23 +140,34 @@ export default function Product() {
|
||||
variationValue = v.attributes[attrNameLower];
|
||||
}
|
||||
|
||||
// If key is undefined/missing in variation, it means "Any" -> Match
|
||||
// If key is undefined/missing in variation, it means "Any" -> Match with score 0
|
||||
if (variationValue === undefined || variationValue === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If empty string, it also means "Any" -> Match
|
||||
// If empty string, it also means "Any" -> Match with score 0
|
||||
const normalizedVarValue = String(variationValue).toLowerCase().trim();
|
||||
if (normalizedVarValue === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, values must match
|
||||
return normalizedVarValue === normalizedSelectedValue;
|
||||
// Exact match gets a higher score
|
||||
if (normalizedVarValue === normalizedSelectedValue) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Value mismatch
|
||||
return false;
|
||||
});
|
||||
|
||||
if (attributesMatch && score > highestScore) {
|
||||
highestScore = score;
|
||||
bestMatch = v;
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedVariation(variation || null);
|
||||
setSelectedVariation(bestMatch || null);
|
||||
} else if (product?.type !== 'variable') {
|
||||
setSelectedVariation(null);
|
||||
}
|
||||
@@ -317,357 +342,364 @@ export default function Product() {
|
||||
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
<nav className="mb-6 text-sm">
|
||||
<Link to="/shop" className="text-gray-600 hover:text-gray-900">
|
||||
Shop
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span className="text-gray-900">{product.name}</span>
|
||||
</nav>
|
||||
)}
|
||||
{/* Flat: entire Container is bg-white. Card: per-section white cards on gray. */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Top section: flat = no card wrapper, card = white card */}
|
||||
<div className={layout.layout_style === 'card' ? 'bg-white rounded-2xl shadow-sm border border-gray-100 p-6 lg:p-8 xl:p-10 mb-8' : 'mb-8'}>
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
<nav className="mb-6 text-sm">
|
||||
<Link to="/shop" className="text-gray-600 hover:text-gray-900">
|
||||
Shop
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span className="text-gray-900">{product.name}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 lg:gap-12 ${layout.image_position === 'right' ? 'lg:grid-cols-[42%_58%]' : 'lg:grid-cols-[58%_42%]'}`}>
|
||||
{/* Product Images */}
|
||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||
{/* Main Image - ENHANCED */}
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
||||
{selectedImage ? (
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full !h-full object-contain p-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="!h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-24 h-24 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">No image available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sale Badge on Image */}
|
||||
{isOnSale && (
|
||||
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
|
||||
Sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dots Navigation - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<div className="flex gap-2">
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Slider - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{/* Left Arrow */}
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable Thumbnails */}
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-3 overflow-x-auto scroll-smooth scrollbar-hide px-10"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={`${product.name} ${index + 1}`}
|
||||
className="w-full !h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Arrow */}
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
{/* Product Title - PRIMARY HIERARCHY - SERIF FONT */}
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-serif font-light mb-4 leading-tight text-gray-900">{product.name}</h1>
|
||||
|
||||
{/* Price - SECONDARY (per UI/UX Guide) */}
|
||||
<div className="mb-6">
|
||||
{isOnSale && regularPrice ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
{formatPrice(currentPrice)}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 line-through ml-3">
|
||||
{formatPrice(regularPrice)}
|
||||
</span>
|
||||
<span className="inline-block bg-red-50 text-red-600 px-3 py-1 rounded-md text-sm font-semibold ml-3">
|
||||
Save {Math.round((1 - parseFloat(currentPrice) / parseFloat(regularPrice)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-gray-900">{formatPrice(currentPrice)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock Status Badge */}
|
||||
<div className="mb-6">
|
||||
{stockStatus === 'instock' ? (
|
||||
<div className="inline-flex items-center gap-2 text-green-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>In Stock • Ships Today</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 text-red-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Out of Stock</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
{product.short_description && (
|
||||
<div
|
||||
className="prose prose-sm text-gray-600 leading-relaxed mb-6 border-l-4 border-gray-200 pl-4"
|
||||
dangerouslySetInnerHTML={{ __html: product.short_description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Variation Selector - PILLS (per UI/UX Guide) */}
|
||||
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
{product.attributes.map((attr: any, index: number) => (
|
||||
attr.variation && (
|
||||
<div key={index}>
|
||||
<label className="block font-medium mb-3 text-sm text-gray-700 uppercase tracking-wider">{attr.name}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attr.options && attr.options.map((option: string, optIndex: number) => {
|
||||
const isSelected = selectedAttributes[attr.name] === option;
|
||||
return (
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
|
||||
{/* Product Images */}
|
||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||
{/* Main Image - ENHANCED */}
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
||||
{selectedImage ? (
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full !h-full object-contain p-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="!h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-24 h-24 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">No image available</p>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity & Add to Cart */}
|
||||
{stockStatus === 'instock' && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Quantity Selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
||||
<div className="flex items-center border-2 border-gray-200 rounded-xl">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-l-md"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-14 text-center border-x-2 border-gray-200 focus:outline-none font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-r-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - PROMINENT */}
|
||||
{/* Add to Cart Button */}
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="w-full h-14 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold text-base hover:bg-gray-800 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Add to Cart
|
||||
</button>
|
||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||
<button
|
||||
onClick={() => product && toggleWishlist(product.id)}
|
||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||
</button>
|
||||
)}
|
||||
{/* Sale Badge on Image */}
|
||||
{isOnSale && (
|
||||
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
|
||||
Sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust Badges - REDESIGNED */}
|
||||
<div className="grid grid-cols-3 gap-4 py-6 border-y border-gray-200">
|
||||
{/* Free Shipping */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
{/* Dots Navigation - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<div className="flex gap-2">
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
||||
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Returns */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
||||
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
||||
</div>
|
||||
{/* Thumbnail Slider - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{/* Left Arrow */}
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Secure */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
{/* Scrollable Thumbnails */}
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-3 overflow-x-auto scroll-smooth scrollbar-hide px-10"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={`${product.name} ${index + 1}`}
|
||||
className="w-full !h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Arrow */}
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Secure Payment</p>
|
||||
<p className="text-xs text-gray-500 mt-1">SSL encrypted</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Meta */}
|
||||
{elements.product_meta && (
|
||||
<div className="space-y-2 text-sm border-t pt-4 border-gray-200">
|
||||
{product.sku && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">SKU:</span>
|
||||
<span className="font-medium">{product.sku}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">Categories:</span>
|
||||
<span className="font-medium">
|
||||
{product.categories.map((cat: any) => cat.name).join(', ')}
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
{/* Product Title - PRIMARY HIERARCHY - SERIF FONT */}
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-serif font-light mb-4 leading-tight text-gray-900">{product.name}</h1>
|
||||
|
||||
{/* Price - SECONDARY (per UI/UX Guide) */}
|
||||
<div className="mb-6">
|
||||
{isOnSale && regularPrice ? (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
{formatPrice(currentPrice)}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 line-through ml-3">
|
||||
{formatPrice(regularPrice)}
|
||||
</span>
|
||||
<span className="inline-block bg-red-50 text-red-600 px-3 py-1 rounded-md text-sm font-semibold ml-3">
|
||||
Save {Math.round((1 - parseFloat(currentPrice) / parseFloat(regularPrice)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-gray-900">{formatPrice(currentPrice)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Buttons */}
|
||||
{elements.share_buttons && (
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
||||
<div className="flex gap-2">
|
||||
{/* Stock Status Badge */}
|
||||
<div className="mb-6">
|
||||
{stockStatus === 'instock' ? (
|
||||
<div className="inline-flex items-center gap-2 text-green-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>In Stock • Ships Today</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 text-red-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Out of Stock</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
{product.short_description && (
|
||||
<div
|
||||
className="prose prose-sm text-gray-600 leading-relaxed mb-6 border-l-4 border-gray-200 pl-4"
|
||||
dangerouslySetInnerHTML={{ __html: product.short_description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Variation Selector - PILLS (per UI/UX Guide) */}
|
||||
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
{product.attributes.map((attr: any, index: number) => (
|
||||
attr.variation && (
|
||||
<div key={index}>
|
||||
<label className="block font-medium mb-3 text-sm text-gray-700 uppercase tracking-wider">{attr.name}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attr.options && attr.options.map((option: string, optIndex: number) => {
|
||||
const isSelected = selectedAttributes[attr.name] === option;
|
||||
return (
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity & Add to Cart */}
|
||||
{stockStatus === 'instock' && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Quantity Selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
||||
<div className="flex items-center border-2 border-gray-200 rounded-xl">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-l-md"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-14 text-center border-x-2 border-gray-200 focus:outline-none font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-r-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - PROMINENT */}
|
||||
{/* Add to Cart Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Facebook"
|
||||
onClick={handleAddToCart}
|
||||
className="w-full h-14 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold text-base hover:bg-gray-800 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Add to Cart
|
||||
</button>
|
||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||
<button
|
||||
onClick={() => product && toggleWishlist(product.id)}
|
||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust Badges - REDESIGNED */}
|
||||
<div className="grid grid-cols-3 gap-4 py-6 border-y border-gray-200">
|
||||
{/* Free Shipping */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
||||
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
||||
</div>
|
||||
|
||||
{/* Returns */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
||||
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
||||
</div>
|
||||
|
||||
{/* Secure */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Secure Payment</p>
|
||||
<p className="text-xs text-gray-500 mt-1">SSL encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Meta */}
|
||||
{elements.product_meta && (
|
||||
<div className="space-y-2 text-sm border-t pt-4 border-gray-200">
|
||||
{product.sku && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">SKU:</span>
|
||||
<span className="font-medium">{product.sku}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">Categories:</span>
|
||||
<span className="font-medium">
|
||||
{product.categories.map((cat: any) => cat.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Buttons */}
|
||||
{elements.share_buttons && (
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */}
|
||||
<div className="mt-12 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Description Section */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className={layout.layout_style === 'card'
|
||||
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
|
||||
: 'border-t border-gray-200 overflow-hidden'
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
||||
<svg
|
||||
@@ -694,10 +726,13 @@ export default function Product() {
|
||||
</div>
|
||||
|
||||
{/* Specifications Section - SCANNABLE TABLE */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className={layout.layout_style === 'card'
|
||||
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
|
||||
: 'border-t border-gray-200 overflow-hidden'
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
||||
<svg
|
||||
|
||||
39
customer-spa/src/pages/Subscribe/index.tsx
Normal file
39
customer-spa/src/pages/Subscribe/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { NewsletterForm } from '@/components/NewsletterForm';
|
||||
|
||||
export default function Subscribe() {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center py-20 px-4 bg-gray-50/50">
|
||||
<Helmet>
|
||||
<title>Subscribe | WooNooW</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-md w-full bg-white p-8 md:p-10 rounded-2xl shadow-sm border border-gray-100 text-center space-y-6">
|
||||
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight">
|
||||
Subscribe to our Newsletter
|
||||
</h1>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Get the latest updates, articles, and exclusive offers straight to your inbox. No spam, ever.
|
||||
</p>
|
||||
|
||||
<div className="pt-4 mt-8 text-left">
|
||||
<NewsletterForm
|
||||
gdprRequired={true}
|
||||
consentText="I agree to receive marketing emails and understand I can unsubscribe at any time."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-6 pt-6 border-t border-gray-100">
|
||||
By subscribing, you agree to our Terms of Service and Privacy Policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,5 +25,5 @@ module.exports = {
|
||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||
};
|
||||
Reference in New Issue
Block a user