Compare commits
867 Commits
ac2add1984
...
feature/ai
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ad12a9818 | |||
| 9ba1ddf2a6 | |||
| a558b18cd8 | |||
| 482460b336 | |||
| 943c6ed09d | |||
| c45a90082b | |||
| fb98c8237e | |||
| b84e0d782d | |||
| ef4f0e7ace | |||
| d6bdd28b30 | |||
| 289e41a040 | |||
| b0d66c4192 | |||
| 040809812a | |||
| 678ca784a1 | |||
| 03d10f9a83 | |||
| 4f464f8bb7 | |||
| 975ac79059 | |||
| eae46d3048 | |||
| 7e0e01ecac | |||
| a5db900d3f | |||
| dd27f77c71 | |||
| 5144378c7e | |||
| 53d1e62b42 | |||
| 3f6bbff409 | |||
| d575e5924a | |||
| c804b051e0 | |||
| 1dc52da677 | |||
| 7f1aeaee7e | |||
| 590c542b73 | |||
| ab477e8d8e | |||
| d83ab182db | |||
| 159ee66f55 | |||
| 45c385d62e | |||
| 8e99c9d121 | |||
| 7583540de2 | |||
| c979647287 | |||
| 20051244d9 | |||
| b80136894c | |||
| 3acf0c3740 | |||
| 4dcdb717f0 | |||
| 81ce3a4588 | |||
| 8b2e82888c | |||
| da637d4a74 | |||
| 9c4c8e28e9 | |||
| 761c6be80a | |||
| 663aaefc4f | |||
| 7d3737a88d | |||
| e32446fedb | |||
| 5552d952aa | |||
| 3f67e1c333 | |||
| ca2839017e | |||
| e6c4af1606 | |||
| 34d341f5ae | |||
| 42c287f519 | |||
| fe3cb37351 | |||
| c614cf9867 | |||
| a32fff7d20 | |||
| ce7fefd99f | |||
| 2d2958301a | |||
| 27aaf3b0ca | |||
| a2729689d5 | |||
| 1640b57c87 | |||
| 57d624839d | |||
| e3393d73c3 | |||
| 94e15656a4 | |||
| d41ec9b66b | |||
| 68380a3af9 | |||
| 3496720e22 | |||
| 8162a8b1cb | |||
| 6729d53c78 | |||
| 884adeabaf | |||
| a66674fcdf | |||
| ee7a40d39b | |||
| 79bbf852a1 | |||
| 8adc3c5b51 | |||
| d89b7930c2 | |||
| 6cea2f0c45 | |||
| 9be7cb7ac9 | |||
| e7116f75fc | |||
| dacb100218 | |||
| 1862b54540 | |||
| df2514e461 | |||
| 1434569dd8 | |||
| 7bab6a55db | |||
| 5797261d1a | |||
| 88dfeba502 | |||
| f1d0227260 | |||
| 69b8ae9067 | |||
| 49d9902dc3 | |||
| 4ff50603e4 | |||
| cb47add128 | |||
| 35db587a0d | |||
| 6e80c91f7d | |||
| 2e706b1946 | |||
| 90542c9388 | |||
| ce8829ece5 | |||
| 6f393fbc59 | |||
| b1363d9d52 | |||
| 0bd55c1dee | |||
| bb7c50c780 | |||
| 296ead2c74 | |||
| a1a6992f8e | |||
| 3c89c50a88 | |||
| 118cecf423 | |||
| 9a72306227 | |||
| 4ca0b53bd9 | |||
| c8286f4f67 | |||
| 73c8b4dcc2 | |||
| 7ca1b9c143 | |||
| 3570e766f6 | |||
| ec0abffc55 | |||
| 4aa179df4c | |||
| b248af400b | |||
| 63884ff258 | |||
| 6d0d086622 | |||
| 561d1938c5 | |||
| 949cac8bf8 | |||
| c16b0e01cb | |||
| 99a0e05499 | |||
| bbbad1fbc7 | |||
| 21c1c6282f | |||
| 371e835853 | |||
| 001ebe28ef | |||
| a670c5fd65 | |||
| 70f189b0c9 | |||
| d5dd66b832 | |||
| f8fc6fcbbe | |||
| 4e0d8a0f3a | |||
| 11723bf184 | |||
| 1756b630ef | |||
| daabf8bb63 | |||
| e524c9faf6 | |||
| 15279c8be1 | |||
| 583a3797f3 | |||
| 655f33091f | |||
| 34bb91c04b | |||
| 449b7bc8aa | |||
| b033142599 | |||
| 02be8e59b2 | |||
| d2418b5720 | |||
| 501f9659a1 | |||
| e9ceae3989 | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c | |||
| 57b6963efe | |||
| 1a136540d0 | |||
| 92bc88dfbd | |||
| ec3f2cf8c9 | |||
| fb3ec6e10a | |||
| 3a61d01384 | |||
| acf642d7e6 | |||
| 17ebde407e | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 56cd1fb1ba | |||
| 437dd35c9c | |||
| 4adf547265 | |||
| 0cb96dfbac | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| f1a28b9db2 | |||
| 7fb1945ce5 | |||
| ce719a1d70 | |||
| bd2f92125b | |||
| ec013a32a2 | |||
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 | |||
| 0be885428d | |||
| 009f12a3bf | |||
| 8e2a06d6f2 | |||
| 4f2bf3fa51 | |||
| 064ebf45e3 | |||
| e6dfeaffef | |||
| 7cdfe5d7f8 | |||
| 83f4b8eea8 | |||
| 97e76c7cac | |||
| 6caa850045 | |||
| 04ce0ecedd | |||
| 083859d52d | |||
| a13074902b | |||
| 4280f11772 | |||
| 3049c1b6e7 | |||
| 647f9a5f19 | |||
| a2872be02e | |||
| 9c3c7bd34b | |||
| 45602db7ff | |||
| 89405e6e18 | |||
| 57d54231eb | |||
| 5c4225d0a9 | |||
| e1101f2e60 | |||
| 0be6076512 | |||
| 62400943c2 | |||
| 4c60029e21 | |||
| b3c5b911d9 | |||
| 89f00c79a1 | |||
| 98ac3dbd10 | |||
| 0db4c819ff | |||
| 08a3b0be7b | |||
| a953820241 | |||
| fa02ac597f | |||
| 40e26117bd | |||
| 925765233e | |||
| 20fd889751 | |||
| 0487bd8ebe | |||
| 87b2624ab3 | |||
| 7cad437eb4 | |||
| f8b7d4f59d | |||
| 7fb4d306c3 | |||
| 294907977d | |||
| 3de13b4fb3 | |||
| 7d65237ee9 | |||
| 1963a93123 | |||
| 44d3e8585b | |||
| 5652f27c71 | |||
| c769da5f26 | |||
| ef5e749056 | |||
| 9c2344afd9 | |||
| 0b3de9f98c | |||
| 5813b4bd49 | |||
| 33f0238d58 | |||
| d5da64cb76 | |||
| c3111a04d8 | |||
| 2fabfc4445 | |||
| fb62113a32 | |||
| bdde7c242c | |||
| 90f657ce8d | |||
| a168f96f3c | |||
| 2db2a3aff9 | |||
| 2ba67af68a | |||
| b0f088a1dc | |||
| f358492a99 | |||
| 32576b5391 | |||
| 1e9cf7d9ab | |||
| f0f840ad5a | |||
| ca352fea3a | |||
| 323886443f | |||
| c5851370bf | |||
| 0186dd2dc9 | |||
| 82156d30f7 | |||
| 3dcde28071 | |||
| c4fca24eca | |||
| 2435b968cc | |||
| b6a1ebd236 | |||
| aa0c9cd9f5 | |||
| a3899f6cdd | |||
| a960a7b139 | |||
| 824ee3cb75 | |||
| 28633f187c | |||
| 51e0d86a6c | |||
| 923ff2071b | |||
| 30eb2e6e0e | |||
| dd830f9077 | |||
| ba16f1d7aa | |||
| 0842c136a6 | |||
| 36b8e64d69 | |||
| 4833af81f4 | |||
| 5f766589c4 | |||
| 56a7613e85 | |||
| c7c345eaad | |||
| ec99dc0317 | |||
| a6dd7913a7 | |||
| 388b90ddb0 | |||
| d57700d322 | |||
| f7aa880d9f | |||
| 2bac8d6e8a | |||
| 5bd95bca4f | |||
| 6f8d63200a | |||
| 4742630260 | |||
| a5d77fc69b | |||
| 41cfe19cbf | |||
| e4f68713e7 | |||
| 2fbcce0990 | |||
| c414a7614b | |||
| 6a0269facc | |||
| 477a3bb8ce | |||
| b1859c15ce | |||
| 6085cc05dc | |||
| bcf2d60da6 | |||
| f4fdb89ba4 | |||
| 9de3931e33 | |||
| b10dbcb23f | |||
| 65bb9c620a | |||
| 63853ffa89 | |||
| 9694c77ef7 | |||
| 2c11b5026a | |||
| eaa90c65f1 | |||
| 2a47d22e26 | |||
| 33d2d67774 | |||
| 3de62dba04 | |||
| fb2354d2cc | |||
| 70984b9021 | |||
| e1b441e8e7 | |||
| 470e532d2c | |||
| 1d24a8fb7a | |||
| 73c4988eb2 | |||
| 4a75db5f54 | |||
| d76fadd6e8 | |||
| 4b2638caed | |||
| a6dcc64833 | |||
| a55680ed41 | |||
| 1a39e9c0e4 | |||
| 16723a04b7 | |||
| 639e25276f | |||
| ad2936bf93 | |||
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 | |||
| a5384134e7 | |||
| 4965e4ae26 | |||
| 1153a79eb6 | |||
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 | |||
| 2b09cfc5d9 | |||
| 927ce977f2 | |||
| 85bc03b9d2 | |||
| c4bc10ef76 | |||
| e95f7c6dd2 | |||
| 17a91e48e6 | |||
| 4d0a94d288 | |||
| 3568c13941 | |||
| d538d7b9ec | |||
| 8c08b552cf | |||
| 1dd74a3861 | |||
| 8d77ca45f7 | |||
| c646815a3a | |||
| 23bf327670 | |||
| c77f99ef37 | |||
| bffcc98820 | |||
| 7519e17280 | |||
| 5bd7421764 | |||
| d7aba218d9 | |||
| e20d7f42c0 | |||
| 16d06d3275 | |||
| 7542f42568 | |||
| 474fa4f3df | |||
| f1d49416d1 | |||
| e3e0a7670c | |||
| 8a87318b12 | |||
| 93cb12d7d9 | |||
| 44f0c430a9 | |||
| 1478909a73 | |||
| 837abd4921 | |||
| 75c6d363c0 | |||
| a2b7f28b9f | |||
| 52ecd1b052 | |||
| f0672600e4 | |||
| 61daeaf03f | |||
| 9d935ce03b | |||
| 9fab9a4536 | |||
| 291f6aa34f | |||
| a111851176 | |||
| 64c6873735 | |||
| 0d39beef70 | |||
| 95d0d094e1 | |||
| 38cf6a8d75 | |||
| ea55580e18 | |||
| df2dd23206 | |||
| 374fcc9689 | |||
| 02bd1dcd7f | |||
| 4b0433394f | |||
| d9bddae20e | |||
| e7c482dabf | |||
| 8974d89b33 | |||
| f99ca4d35d | |||
| d10f15abe3 | |||
| 9bdbcc2803 | |||
| b08f07494c | |||
| 1f758758e3 | |||
| fb8d9574b6 | |||
| 6856b7835c | |||
| 1d074ba6d2 | |||
| 0e972983bc | |||
| c979582193 | |||
| e47ba31763 | |||
| 28072908f7 | |||
| 7e6b4a3ed7 | |||
| d7e5a57344 | |||
| c859d5e677 | |||
| e036dea089 | |||
| 39088ca868 | |||
| 18f9104623 | |||
| 76f745cc87 | |||
| 848d58010f | |||
| c0f5799667 | |||
| 0e089f9471 | |||
| 52b17423dd | |||
| bfd3c8164b | |||
| b091175b89 | |||
| 1baf03a84e | |||
| 483dfabe10 | |||
| 65f8b2c485 | |||
| 90cdd7e713 | |||
| 40fa2a7721 | |||
| a136e7b4a7 | |||
| e615d88fd8 | |||
| 3d498f3df8 | |||
| d9a7cf6a77 | |||
| cd7be080d7 | |||
| 4e602da15d | |||
| e47982d394 | |||
| 877108020b | |||
| 0fff5ae52a | |||
| 459716d09c | |||
| a0d4023f89 | |||
| 9746416146 | |||
| fc9746335d | |||
| 4058abab13 | |||
| 6074747b34 | |||
| 319b2b3e0c | |||
| d7f5504149 | |||
| 0f705b474b | |||
| 67046b9301 | |||
| 0b6211cf5f | |||
| c7f2c3fdfe | |||
| f30c93ffce | |||
| 3e6bbe9a93 | |||
| c6cbb02dfa | |||
| bec1916ccc | |||
| ab17e9e758 | |||
| f257e5428f | |||
| 797411ccc3 | |||
| 94a609e438 | |||
| 409ac3fea7 | |||
| b3876666c8 | |||
| bd1a61e9cd | |||
| f2ce9ec262 | |||
| eddfa3a1f1 | |||
| 1e77914314 | |||
| 52dfbb3870 | |||
| 72e85b99ee | |||
| c7807610f6 | |||
| 81f0dd88a6 | |||
| 458e467a14 | |||
| 060118202f | |||
| 64af78a984 | |||
| f6d7584613 | |||
| 2192f37fee | |||
| 8a9cd7ef3e | |||
| 406cf22050 | |||
| 5e82d6edc9 | |||
| 85375eefb0 | |||
| fe829b0c4c | |||
| 9ed08004af | |||
| fa6f27114b | |||
| a60e8af26b | |||
| c111efae1a | |||
| a12759d507 | |||
| eefabfa3ff | |||
| 86d28796a7 | |||
| bb9424d482 | |||
| b1515155b7 | |||
| 65d54ae789 | |||
| dc21d480ab | |||
| 51043da882 | |||
| 4a31cddf11 | |||
| 1b999510db | |||
| 0d852db651 | |||
| f3ff9cd364 | |||
| f15957847c | |||
| 55fc63fed5 | |||
| dac719efd2 | |||
| ec3f9d5c8e | |||
| 7ad5b5696d | |||
| 9bcf946752 | |||
| 1fefb794c1 | |||
| 1c1aebb804 | |||
| 30d8645f74 | |||
| 365cd50402 | |||
| a9f03b24c8 | |||
| 79a2a5121e | |||
| b2f26208ad | |||
| 6c739e2726 | |||
| 0ec830f5c6 | |||
| 713908ef95 | |||
| c3f41a24d5 | |||
| 013fbc5d66 | |||
| fd65b19f1d | |||
| 340c145863 | |||
| 2da182ec47 | |||
| 33a0877a6d | |||
| fdd1d5afb7 | |||
| bf996934af | |||
| 3e724f74fa | |||
| cfd5cbda55 | |||
| 0032da1562 | |||
| 7965e9c01a | |||
| f5df62c297 | |||
| 87ef5798d2 | |||
| 90e992636c | |||
| 44dbfdb3a8 | |||
| f60288a06c | |||
| 5906fc3375 | |||
| f36c6731e8 | |||
| 65ce8adc5d | |||
| 1d7c52fbca | |||
| 16f0e9b4e5 | |||
| 8dc41d52ed | |||
| 169b25ea12 | |||
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 | |||
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 698141f70b | |||
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 | |||
| e3f7344daf | |||
| 21a7b0ade2 | |||
| d027fbeac2 | |||
| 8a751998eb | |||
| 48c3e1d013 | |||
| 3df4b44b8d | |||
| 07e0f237b9 | |||
| 57a3944301 | |||
| 5fe0a8d83e | |||
| 8062d33f35 | |||
| ebe67afd73 | |||
| b74f6b6f9e | |||
| 24eea9a2fe | |||
| c70288bba7 | |||
| d438dbdc9d | |||
| e0c4aaf298 | |||
| f44487eeac | |||
| a82b95a28f | |||
| ab688a3dab | |||
| a0ce37708e | |||
| 0379d1f05d | |||
| 50347d049d | |||
| 9678181927 | |||
| 3ffaafefe5 | |||
| e5bf8c861c | |||
| 651e14d665 | |||
| 580cd6789c | |||
| db4cf354ff | |||
| e8957e0672 | |||
| 7ef0bca9f6 | |||
| 198944649a | |||
| 6aa741ab0a | |||
| f69952a5da | |||
| 81af9bf3dd | |||
| f1b617e967 | |||
| d6be9beebf | |||
| 0a797260e3 | |||
| 2a4cc76292 | |||
| f87eb27f41 | |||
| acd86099e5 | |||
| 5ab9791c72 | |||
| 8152ccd5df | |||
| 8eeb571c2d | |||
| b1854d5255 | |||
| 7f4f970a38 | |||
| e5908c757c | |||
| 70efb0c593 | |||
| 479a36f1d0 | |||
| 372a0c5cfa | |||
| 42b06e1ef8 | |||
| b25fdd877a | |||
| dd23310ac4 | |||
| f6f28a4529 | |||
| fc3635db86 | |||
| fc000353a9 | |||
| 73c32c6d31 | |||
| 381e0b121f | |||
| 829c074c7f | |||
| 9189e813b2 | |||
| 249313cc37 | |||
| 8232971419 | |||
| f41260e1db | |||
| 950ef9d463 | |||
| fcb3169d04 | |||
| 9e87720494 | |||
| 6a403f47a0 | |||
| 2d8df53e36 | |||
| 6f49dbc56c | |||
| ad2a477636 | |||
| 77a1067820 | |||
| ea3076b4ec | |||
| 17fe0d7107 | |||
| 38b512973b | |||
| 4f73838c21 | |||
| 2f8ce42409 | |||
| cf7af73b72 | |||
| d526bfe56f | |||
| 4cb7d438a0 | |||
| 03e597442b | |||
| 5f9ee7d976 | |||
| a4ea42a043 | |||
| ee04d2422c | |||
| 26fc34299e | |||
| 6d13611a16 | |||
| 4a9246be5e | |||
| 2ed038174d | |||
| c1304403a1 | |||
| 5036c5fe28 | |||
| 50a524c515 | |||
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 | |||
| 06bbed8c21 | |||
| f5a879fa60 | |||
| e4eabd7a86 | |||
| 757df76f36 | |||
| 14b2f83971 | |||
| 51565fdf41 | |||
| be9f9cf483 | |||
| 506c8682fe | |||
| a909de30f3 | |||
| a2f94f15bc | |||
| 13e56a88bc | |||
| bb7d17001b | |||
| 920efa0083 | |||
| 0b81d1a4cb | |||
| 1d5bdeba26 | |||
| a0c3fbbc7e | |||
| 8101a9f156 | |||
| 7b6f4b5ea4 | |||
| 658057cdb1 | |||
| 2aa5d5b00e | |||
| 7f2f6f5aca | |||
| 4e50482769 | |||
| 1da1f05cdd | |||
| 15cfb314b1 | |||
| a3da6192e3 | |||
| af33c6225d | |||
| 9ee09bbe4b | |||
| 3f0858a1ba | |||
| a85fe64ccb | |||
| 21b16a5e6c | |||
| 6115e0e0d4 | |||
| 859d034ed7 | |||
| 91ebc54571 | |||
| d6c1d6bae6 | |||
| 407b2227b3 | |||
| 2896556659 | |||
| 8242687b07 | |||
| dab4f3f5b5 | |||
| b18ee8d7a0 | |||
| cbca29cbcf | |||
| 5a5c10ca36 | |||
| ad6bfe1457 | |||
| b5c3fc6649 | |||
| 03609f113d | |||
| 622180c483 | |||
| 41865ab9ab | |||
| 986fcd067e | |||
| d27616ed43 | |||
| b9a3e47662 | |||
| a5e34053f7 | |||
| e6f9ad36d3 | |||
| 2e0456b081 | |||
| ad37a372a7 | |||
| 8490b691e1 | |||
| 334c76935e | |||
| 6624cfc3ad | |||
| 20d7d8405a | |||
| 12501ea51a | |||
| 70f1813e33 | |||
| 69e39b06cf | |||
| 3b5174cd12 | |||
| 875cf1bd07 | |||
| e284bb94af | |||
| 8eea94ceda | |||
| 05b10018a6 | |||
| 3b493abb3d | |||
| f54d8277b3 | |||
| 5c71e9a064 | |||
| 3cab376cd1 | |||
| 3c45e5563e | |||
| 13ab4bde75 | |||
| a805c7b8de | |||
| b8fdbfb10b | |||
| 081adb02be | |||
| 3f17d08b04 | |||
| d40f4544ea | |||
| 041b5534c9 | |||
| dea3b57627 | |||
| baec7cc94a | |||
| b43b1c4314 | |||
| 615757963c | |||
| 0094871358 | |||
| 8fef3b6c7f | |||
| cbb7855804 | |||
| a8f7c5370b | |||
| f459bf230d | |||
| 570a4977dd | |||
| da04c108ef | |||
| cb207d6a01 | |||
| 6dc0ff4644 | |||
| e648d30767 | |||
| 9c26ddddbf | |||
| 61b4b37111 | |||
| feedf30be1 | |||
| b596c22011 | |||
| 377f583ff1 | |||
| 4d72a5bf86 | |||
| f50f41530d | |||
| f8274cad1b | |||
| f2dd76a7a6 | |||
| 574d5a8a9a | |||
| ac1e22017e | |||
| a503d3e539 | |||
| c2eeeafd56 | |||
| 78cb7207e6 | |||
| ca4c36ad01 | |||
| c9dcf73021 | |||
| 8146ee78fa | |||
| 58878e9f64 | |||
| f43c97e712 | |||
| 888b46eed0 | |||
| f1c64f000c | |||
| 9b561f2176 | |||
| f7930503d5 | |||
| bb98014ea1 | |||
| defde86fc9 | |||
| 69141175cf | |||
| 4fdbc0f5cf | |||
| 97950d574e | |||
| c04a134eca | |||
| 3b03572fb0 | |||
| a4c926ceb1 | |||
| f5edc08e9d | |||
| 28a1cb4b4c | |||
| 8dcc27ffcd | |||
| a9d256ea55 | |||
| 97b1a94012 | |||
| 831c8be588 | |||
| c121398381 | |||
| a831bed335 | |||
| 5659073f95 | |||
| 2d93321a91 | |||
| fb6af84a42 | |||
| 8affb7878f | |||
| c074a5d935 | |||
| 4dbf566f0c | |||
| a0cfa8ef62 | |||
| ae1e0ad8a9 | |||
| 5ba3afc393 | |||
| 1380d40b4d | |||
| cf5df1b46b | |||
| dd9f427ad5 | |||
| 2c4345a7bd | |||
| 6fdf9c3464 | |||
| aac2cb2041 | |||
| b3827b95c2 | |||
| 339a272105 | |||
| 3582370449 | |||
| 3288bbd745 | |||
| c2d6e082e8 | |||
| 4777091d8e | |||
| 5afa5395d4 | |||
| 1568ecdf7d | |||
| 807a604e39 | |||
| 72711c74ba | |||
| 7e94feaf19 | |||
| d90d7502c3 | |||
| e5e2b646a0 | |||
| 899b3c7ed4 | |||
| 1a646282a0 | |||
| 84438f1492 | |||
| dd5636450c | |||
| ae60037708 | |||
| 41dd897f08 | |||
| 1d472062b1 | |||
| 656852e983 | |||
| a668dc60a9 | |||
| 83411789a5 | |||
| 33f188c16a | |||
| 22bd212c11 | |||
| e74c309461 | |||
| 2fcca45e4a | |||
| 846319b2c7 | |||
| ded9150df6 | |||
| d16c28edb1 | |||
| d8b268fc23 | |||
| a7f5c1c16d | |||
| abf283c9ab | |||
| f62485a67d | |||
| 1293adbef2 | |||
| 2ffbe8b449 | |||
| 45234c844d | |||
| a62a29b1ad | |||
| ef47fa914c | |||
| 6e34392976 | |||
| 6adf97a096 | |||
| 4abcc3fdf5 | |||
| 6797303628 | |||
| 2feb73b982 | |||
| b99258f886 | |||
| 5c9b2e3f5a | |||
| e8b6b13a3b | |||
| 1f624f3d7f | |||
| 40c553d6f6 | |||
| a32c12692c | |||
| 79016fbe97 | |||
| 4f6264f2e2 | |||
| 46266a7bbc |
@@ -1,10 +1,20 @@
|
||||
node_modules
|
||||
.next
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
.gitea
|
||||
.github
|
||||
.turbo
|
||||
reference/
|
||||
.next
|
||||
!.next/cache
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
*.md
|
||||
docs
|
||||
reference
|
||||
scripts
|
||||
public/datasheets/*.pdf
|
||||
.pnpm-store
|
||||
.gitea
|
||||
|
||||
4
.env
4
.env
@@ -1,4 +0,0 @@
|
||||
WOOCOMMERCE_URL=https://klz-cables.com
|
||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
||||
WORDPRESS_APP_PASSWORD=DlJH 49dp fC3a Itc3 Sl7Z Wz0k‘
|
||||
111
.env.example
Normal file
111
.env.example
Normal file
@@ -0,0 +1,111 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Environment Configuration
|
||||
# ============================================================================
|
||||
# Copy this file to .env for local development
|
||||
# For production, use .env.production as a template
|
||||
# ============================================================================
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Application Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
TARGET=development
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable error tracking
|
||||
SENTRY_DSN=
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Email Configuration (SMTP)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Required for contact form functionality
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
SENTRY_DSN=
|
||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# AI Agent (Payload CMS Agent via OpenRouter)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Required for the Payload CMS AI Chat Agent
|
||||
MISTRAL_API_KEY=
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Payload Infrastructure (Dockerized)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||
# manually write the connection strings here.
|
||||
PAYLOAD_DB_NAME=payload
|
||||
PAYLOAD_DB_USER=payload
|
||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Deployment Configuration (CI/CD only)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# These are typically set by the CI/CD workflow
|
||||
IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
|
||||
# Next.js will proxy requests from /_img to this URL.
|
||||
IMGPROXY_URL=https://img.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
VARNISH_CACHE_SIZE=256M
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT NOTES
|
||||
# ============================================================================
|
||||
#
|
||||
# BUILD-TIME vs RUNTIME Variables:
|
||||
# ─────────────────────────────────
|
||||
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
|
||||
# They must be provided as --build-arg when building the Docker image
|
||||
#
|
||||
# • All other variables are used at RUNTIME only
|
||||
# They are loaded from the .env file by docker-compose
|
||||
#
|
||||
# Docker Deployment:
|
||||
# ──────────────────
|
||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
||||
# 2. Runtime: All vars are loaded from .env file on the server
|
||||
# 3. Branch Deployments:
|
||||
# - main branch uses .env.prod
|
||||
# - staging branch uses .env.staging
|
||||
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
|
||||
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
|
||||
#
|
||||
# Security:
|
||||
# ─────────
|
||||
# • NEVER commit .env files with real credentials to git
|
||||
# • Use Gitea/GitHub secrets for CI/CD workflows
|
||||
# • Store production .env file securely on the server only
|
||||
#
|
||||
# ============================================================================
|
||||
30
.env.production
Normal file
30
.env.production
Normal file
@@ -0,0 +1,30 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# This file contains runtime environment variables for the production deployment.
|
||||
# It should be placed on the production server at: /home/deploy/sites/klz-cables.com/.env
|
||||
#
|
||||
# IMPORTANT: This file contains sensitive data and should NEVER be committed to git.
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
@@ -1,5 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
reference/
|
||||
public/
|
||||
dist/
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-const": "warn",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,81 +3,645 @@ name: Build & Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_checks:
|
||||
description: 'Skip tests? (true/false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||
|
||||
concurrency:
|
||||
group: deploy-pipeline
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 1: Prepare Environment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
prepare:
|
||||
name: 🔍 Prepare
|
||||
runs-on: docker
|
||||
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
env_file: ${{ steps.determine.outputs.env_file }}
|
||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
slug: ${{ steps.determine.outputs.slug }}
|
||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# --- Tools ---
|
||||
- name: Install tools
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
git \
|
||||
docker.io \
|
||||
openssh-client \
|
||||
rsync
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=24h"
|
||||
|
||||
# --- Checkout ---
|
||||
- name: Checkout repo
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/klz-cables.com.git .
|
||||
git checkout main
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# --- Docker registry login ---
|
||||
- name: Login to registry
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||
- name: 🔍 Environment ermitteln
|
||||
id: determine
|
||||
shell: bash
|
||||
run: |
|
||||
echo "$REGISTRY_PASS" | docker login registry.infra.mintel.me \
|
||||
-u "$REGISTRY_USER" \
|
||||
--password-stdin
|
||||
REF="${{ github.ref_name }}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
DOMAIN="klz-cables.com"
|
||||
PRJ="klz"
|
||||
|
||||
# --- Build image ---
|
||||
- name: Build image
|
||||
run: |
|
||||
docker build \
|
||||
--pull \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest .
|
||||
|
||||
# --- Push image ---
|
||||
- name: Push image
|
||||
run: |
|
||||
docker push registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
|
||||
# --- SSH setup ---
|
||||
- name: Setup SSH
|
||||
env:
|
||||
ALPHA_SSH_KEY: ${{ secrets.ALPHA_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$ALPHA_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts
|
||||
|
||||
# --- Sync compose (yml OR yaml) ---
|
||||
- name: Sync compose file
|
||||
run: |
|
||||
rsync -av ./docker-compose.y*ml \
|
||||
deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||
|
||||
# --- Sync varnish config (optional) ---
|
||||
- name: Sync varnish config
|
||||
run: |
|
||||
if [ -d ./varnish ]; then
|
||||
rsync -av ./varnish/ deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/varnish/
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.prod"
|
||||
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||
else
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
TARGET="branch"
|
||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||
ENV_FILE=".env.branch-${SLUG}"
|
||||
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
|
||||
fi
|
||||
|
||||
# --- Deploy ---
|
||||
- name: Deploy on server
|
||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$PRIMARY_HOST"
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
echo "project_name=klz-cablescom"
|
||||
elif [[ "$TARGET" == "branch" ]]; then
|
||||
echo "project_name=$PRJ-branch-$SLUG"
|
||||
else
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
fi
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
echo "slug=$SLUG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
# 1. Discovery (Works without token for public repositories)
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||
|
||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
|
||||
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||
|
||||
if [[ -n "$POLL_TOKEN" ]]; then
|
||||
echo "⏳ GITEA_PAT found. Checking upstream build status..."
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
else
|
||||
echo "ℹ️ No GITEA_PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Typecheck, Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
ssh deploy@alpha.mintel.me '
|
||||
cd /home/deploy/sites/klz-cables.com &&
|
||||
docker compose -f docker-compose.yml pull 2>/dev/null ||
|
||||
docker compose -f docker-compose.yaml pull &&
|
||||
docker image prune -f &&
|
||||
docker compose up -d
|
||||
'
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: 🔒 Security Audit
|
||||
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: "1"
|
||||
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: [prepare, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build, qa]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||
|
||||
# Secrets mapping (Payload CMS)
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
|
||||
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
|
||||
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Search & AI
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY || vars.MISTRAL_API_KEY }}
|
||||
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
||||
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
||||
KABELFACHMANN_MCP_URL: ${{ secrets.KABELFACHMANN_MCP_URL || vars.KABELFACHMANN_MCP_URL || 'http://klz-kabelfachmann:3007/sse' }}
|
||||
# Container Registry (standalone)
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: 📝 Generate Environment
|
||||
shell: bash
|
||||
env:
|
||||
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
# Middleware Selection Logic
|
||||
# Regular app routes get auth on non-production
|
||||
# Unprotected routes (/stats, /errors) never get auth
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
|
||||
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
AUTH_MIDDLEWARE="$STD_MW"
|
||||
COMPOSE_PROFILES=""
|
||||
else
|
||||
# Order: Ratelimit -> Forward (Proto) -> Auth -> Compression
|
||||
AUTH_MIDDLEWARE="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,${PROJECT_NAME}-compress"
|
||||
COMPOSE_PROFILES="gatekeeper"
|
||||
fi
|
||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||
|
||||
# Gatekeeper Origin
|
||||
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||
echo "NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN"
|
||||
echo "SENTRY_DSN=$SENTRY_DSN"
|
||||
echo "LOG_LEVEL=$LOG_LEVEL"
|
||||
echo "MAIL_HOST=$MAIL_HOST"
|
||||
echo "MAIL_PORT=$MAIL_PORT"
|
||||
echo "MAIL_USERNAME=$MAIL_USERNAME"
|
||||
echo "MAIL_PASSWORD=$MAIL_PASSWORD"
|
||||
echo "MAIL_FROM=$MAIL_FROM"
|
||||
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
|
||||
echo ""
|
||||
echo "# Payload CMS"
|
||||
echo "PAYLOAD_SECRET=$PAYLOAD_SECRET"
|
||||
echo "PAYLOAD_DB_NAME=$PAYLOAD_DB_NAME"
|
||||
echo "PAYLOAD_DB_USER=$PAYLOAD_DB_USER"
|
||||
echo "PAYLOAD_DB_PASSWORD=$PAYLOAD_DB_PASSWORD"
|
||||
echo ""
|
||||
echo "# Gatekeeper"
|
||||
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
|
||||
echo "AUTH_COOKIE_NAME=klz_gatekeeper_session"
|
||||
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
|
||||
echo ""
|
||||
echo "# Analytics"
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Search & AI"
|
||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||
echo "MISTRAL_API_KEY=$MISTRAL_API_KEY"
|
||||
echo "QDRANT_URL=$QDRANT_URL"
|
||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
||||
echo "REDIS_URL=$REDIS_URL"
|
||||
echo "KABELFACHMANN_MCP_URL=$KABELFACHMANN_MCP_URL"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
|
||||
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||
echo "TRAEFIK_TLS=true"
|
||||
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||
echo "ENV_FILE=$ENV_FILE"
|
||||
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||
} > .env.deploy
|
||||
|
||||
echo "--- Generated .env.deploy ---"
|
||||
cat .env.deploy
|
||||
echo "----------------------------"
|
||||
|
||||
- name: 🔐 Registry Auth
|
||||
id: auth
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
VALID_TOKEN=""
|
||||
VALID_USER=""
|
||||
for T in $TOKENS; do
|
||||
if [ -n "$T" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
VALID_TOKEN="$T"
|
||||
VALID_USER="$U"
|
||||
break 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
||||
echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Determine deployment paths
|
||||
echo "Preparing deployment for $TARGET..."
|
||||
|
||||
# Transfer and Restart
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
elif [[ "$TARGET" == "testing" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||
else
|
||||
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
|
||||
fi
|
||||
# Transfer files
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
|
||||
|
||||
# Branch Seeding Logic (Production -> Branch)
|
||||
if [[ "$TARGET" == "branch" ]]; then
|
||||
echo "🌱 Seeding Branch Environment from Production Database..."
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d klz-db"
|
||||
|
||||
# Wait for DB to be healthy with a 60s timeout
|
||||
echo "⏳ Waiting for branch database to be ready..."
|
||||
ssh root@alpha.mintel.me "
|
||||
for i in {1..30}; do
|
||||
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo '❌ Database failed to become ready after 60 seconds'
|
||||
exit 1
|
||||
" || exit 1
|
||||
|
||||
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
|
||||
echo "📦 Syncing Production DB into Branch DB..."
|
||||
ssh root@alpha.mintel.me "
|
||||
set -e -o pipefail
|
||||
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
|
||||
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
|
||||
" || exit 1
|
||||
|
||||
echo "✅ Branch database and media synced successfully."
|
||||
fi
|
||||
|
||||
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
|
||||
|
||||
# Restart app to pick up clean migration state
|
||||
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||
|
||||
# Generate Excel Datasheets
|
||||
echo "📊 Generating Excel Datasheets on live container..."
|
||||
ssh root@alpha.mintel.me "docker exec $APP_CONTAINER pnpm run excel:datasheets" || echo "⚠️ Excel generation failed (non-blocking)"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success' && true
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/bin/chromium
|
||||
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
apt-get install -y chromium
|
||||
else
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||
- name: 🏥 CMS Deep Health Check
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: |
|
||||
echo "Waiting 10s for app to fully start..."
|
||||
sleep 10
|
||||
echo "Checking basic health..."
|
||||
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||
echo "✅ Basic health OK"
|
||||
echo "Checking CMS DB connectivity..."
|
||||
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||
echo "❌ CMS health check failed!"
|
||||
echo "$RESPONSE"
|
||||
echo ""
|
||||
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||
exit 1
|
||||
}
|
||||
echo "✅ CMS health: $RESPONSE"
|
||||
- name: 🚀 OG Image Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm run check:og
|
||||
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||
with:
|
||||
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
- name: 📊 Excel Datasheet Accessibility Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: |
|
||||
echo "Checking if datasheets directory is reachable..."
|
||||
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
|
||||
# Since the files are in public/datasheets/products/, we check that path.
|
||||
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
|
||||
|
||||
- name: 📝 E2E Form Submission Test
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
run: pnpm run check:forms
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, post_deploy_checks]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
PERF="${{ needs.post_deploy_checks.result }}"
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# Gotify priority scale:
|
||||
# 1-3 = low (silent/info)
|
||||
# 4-5 = normal
|
||||
# 6-7 = high (warning)
|
||||
# 8-10 = critical (alarm)
|
||||
if [[ "$DEPLOY" != "success" ]]; then
|
||||
PRIORITY=10
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="DEPLOY FAILED"
|
||||
elif [[ "$SMOKE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="⚠️"
|
||||
STATUS_LINE="Smoke tests failed"
|
||||
elif [[ "$PERF" != "success" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="📉"
|
||||
STATUS_LINE="Performance degraded"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="All checks passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
236
.gitea/workflows/qa.yml
Normal file
236
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,236 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: 'https://testing.klz-cables.com'
|
||||
PROJECT_NAME: 'klz-2026'
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────
|
||||
# 1. Static Checks (HTML, Assets, HTTP)
|
||||
# ────────────────────────────────────────────────────
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: 🌐 HTML Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:html
|
||||
- name: 🖼️ Broken Assets
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
ASSET_CHECK_LIMIT: 10
|
||||
run: pnpm run check:assets
|
||||
- name: 🔒 HTTP Headers
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:http
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 2. Accessibility (WCAG)
|
||||
# ────────────────────────────────────────────────────
|
||||
a11y:
|
||||
name: ♿ Accessibility
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: ♿ WCAG Scan
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:wcag
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 3. Performance (Lighthouse)
|
||||
# ────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: 🎭 Lighthouse
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome
|
||||
- name: 🎭 Desktop
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||
- name: 📱 Mobile
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 4. Link Check & Dependency Audit
|
||||
# ────────────────────────────────────────────────────
|
||||
links:
|
||||
name: 🔗 Links & Deps
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||
fail: true
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 5. Notification
|
||||
# ────────────────────────────────────────────────────
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, a11y, lighthouse, links]
|
||||
if: failure()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATIC="${{ needs.static.result }}"
|
||||
A11Y="${{ needs.a11y.result }}"
|
||||
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||
LINKS="${{ needs.links.result }}"
|
||||
|
||||
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS="Failed"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS="Passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,2 +1,36 @@
|
||||
node_modules
|
||||
.next
|
||||
.next
|
||||
.DS_Store
|
||||
.pnpm-store
|
||||
public/uploads
|
||||
public/media
|
||||
|
||||
# Lighthouse CI
|
||||
.lighthouseci/
|
||||
lighthouserc.cjs
|
||||
.lighthouserc.json
|
||||
|
||||
# Legacy (Directus) cleanup
|
||||
directus/uploads
|
||||
|
||||
.next-docker
|
||||
|
||||
# Pa11y CI
|
||||
.pa11yci/
|
||||
|
||||
.htmlvalidate-tmp
|
||||
|
||||
# Turborepo
|
||||
.turbo
|
||||
|
||||
# Test Outputs
|
||||
html-errors*.json
|
||||
reference/
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
.env
|
||||
|
||||
# Payload CMS auto-generated
|
||||
# Knowledge base source files
|
||||
kabelhandbuch.txt
|
||||
26
.htmlvalidate.json
Normal file
26
.htmlvalidate.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": ["html-validate:recommended", "html-validate:document"],
|
||||
"rules": {
|
||||
"require-sri": "off",
|
||||
"meta-refresh": "off",
|
||||
"heading-level": "warn",
|
||||
"no-trailing-whitespace": "off",
|
||||
"wcag/h37": "warn",
|
||||
"no-inline-style": "off",
|
||||
"svg-focusable": "off",
|
||||
"attribute-boolean-style": "off",
|
||||
"attr-case": "off",
|
||||
"void-style": "off",
|
||||
"no-implicit-button-type": "off",
|
||||
"unique-landmark": "off",
|
||||
"long-title": "off",
|
||||
"valid-id": "off",
|
||||
"element-required-attributes": "off",
|
||||
"attribute-empty-style": "off",
|
||||
"element-permitted-content": "off",
|
||||
"element-required-content": "off",
|
||||
"element-permitted-parent": "off",
|
||||
"no-implicit-close": "off",
|
||||
"close-order": "off"
|
||||
}
|
||||
}
|
||||
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
32
.husky/pre-push
Executable file
32
.husky/pre-push
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Husky pre-push hook to validate tags
|
||||
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
|
||||
|
||||
z40=0000000000000000000000000000000000000000
|
||||
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
# Check if we are pushing a tag
|
||||
case "$local_ref" in
|
||||
refs/tags/*)
|
||||
tag_name="${local_ref#refs/tags/}"
|
||||
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Invalid tag name '$tag_name'"
|
||||
echo "--------------------------------------------------"
|
||||
echo "Consistency check failed: All tags MUST start with 'v'."
|
||||
echo "Example: v1.0.10"
|
||||
echo ""
|
||||
echo "Please delete the invalid tag and create a new one:"
|
||||
echo " git tag -d $tag_name"
|
||||
echo " git tag v$tag_name"
|
||||
echo "--------------------------------------------------"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
10
.lintstagedrc.cjs
Normal file
10
.lintstagedrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable no-undef */
|
||||
const path = require('path'); // eslint-disable-line @typescript-eslint/no-require-imports
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||
|
||||
module.exports = {
|
||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||
'*.{json,md,css,scss}': ['prettier --write'],
|
||||
};
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
26
.pa11yci.json
Normal file
26
.pa11yci.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"defaults": {
|
||||
"standard": "WCAG2AA",
|
||||
"runners": ["axe", "htmlcs"],
|
||||
"ignore": [],
|
||||
"timeout": 50000,
|
||||
"wait": 1000,
|
||||
"chromeLaunchConfig": {
|
||||
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||
},
|
||||
"threshold": 25
|
||||
},
|
||||
"urls": [
|
||||
"http://localhost:3000/en",
|
||||
"http://localhost:3000/en/blog",
|
||||
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
|
||||
"http://localhost:3000/en/contact",
|
||||
"http://localhost:3000/en/team",
|
||||
"http://localhost:3000/en/products",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables",
|
||||
"http://localhost:3000/en/products/low-voltage-cables",
|
||||
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
|
||||
"http://localhost:3000/en/legal-notice",
|
||||
"http://localhost:3000/en/privacy-policy"
|
||||
]
|
||||
}
|
||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
||||
# Ignore Next.js auto-generated environment file
|
||||
# It often uses different quote styles than our project config
|
||||
next-env.d.ts
|
||||
|
||||
# Ignore build output
|
||||
.next
|
||||
dist
|
||||
out
|
||||
|
||||
# Ignore other potentially generated files
|
||||
pnpm-lock.yaml
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
103
Dockerfile
103
Dockerfile
@@ -1,59 +1,74 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Stage 1: Builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
COPY patches* ./patches/
|
||||
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm store prune && \
|
||||
pnpm install --no-frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Stage 2: Development (Hot-Reloading)
|
||||
FROM base AS development
|
||||
ENV NODE_ENV=development
|
||||
CMD ["pnpm", "dev:local"]
|
||||
|
||||
RUN npm run build
|
||||
# Stage: Migrator
|
||||
FROM base AS migrator
|
||||
ENV NODE_ENV=production
|
||||
CMD ["pnpm", "cms:migrate"]
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Build application
|
||||
# Stage 3: Builder (Production)
|
||||
FROM base AS builder
|
||||
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||
|
||||
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||
ENV RAYON_NUM_THREADS=3
|
||||
ENV UV_THREADPOOL_SIZE=3
|
||||
|
||||
RUN pnpm build
|
||||
# Excel generation moved to post-deploy
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
# Copy standalone output and static files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
18
Dockerfile.dev
Normal file
18
Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Pre-set the pnpm store directory
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set up pnpm store configuration
|
||||
RUN pnpm config set store-dir /pnpm/store
|
||||
|
||||
EXPOSE 3000
|
||||
165
README.md
165
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
|
||||
````bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
@@ -29,9 +31,40 @@ npm run export
|
||||
|
||||
# Or run development server
|
||||
npm run dev
|
||||
|
||||
### 🏗️ CMS (Strapi)
|
||||
The CMS runs in Docker. Use the following npm scripts for local development:
|
||||
|
||||
```bash
|
||||
# Start Strapi and its database
|
||||
npm run cms:dev
|
||||
|
||||
# View logs
|
||||
npm run cms:logs
|
||||
|
||||
# Stop the CMS
|
||||
npm run cms:stop
|
||||
````
|
||||
|
||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||
|
||||
### 🔄 Data & Migration
|
||||
|
||||
To sync data or migrate existing content:
|
||||
|
||||
```bash
|
||||
# Export local data
|
||||
npm run cms:export -- my-data.tar.gz
|
||||
|
||||
# Import data
|
||||
npm run cms:import -- my-data.tar.gz
|
||||
|
||||
# Migrate existing MDX data to Strapi
|
||||
npm run cms:migrate
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SITE_URL=https://klz-cables.com
|
||||
@@ -40,23 +73,19 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||
|
||||
# Umami
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# GlitchTip (Sentry compatible)
|
||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
|
||||
# Redis (optional cache)
|
||||
# Platform provides a shared redis container reachable as `redis`.
|
||||
# Pick a dedicated DB index per app, e.g. redis://redis:6379/2
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
```
|
||||
|
||||
## 📊 Project Overview
|
||||
|
||||
### Migration Statistics
|
||||
|
||||
- **Content Exported**: 141 items
|
||||
- 18 pages (9 EN + 9 DE)
|
||||
- 59 posts (29 EN + 30 DE)
|
||||
@@ -67,6 +96,7 @@ REDIS_KEY_PREFIX=klz:
|
||||
- **Translation Pairs**: 16
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Before**: Dynamic WordPress with database queries
|
||||
- **After**: Static HTML with CDN delivery
|
||||
- **Load Time**: <100ms (vs 500ms+)
|
||||
@@ -75,15 +105,18 @@ REDIS_KEY_PREFIX=klz:
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: SCSS
|
||||
- **Data**: Static JSON (WordPress export)
|
||||
- **CMS**: Strapi (Source of Truth)
|
||||
- **Data**: Static JSON (WordPress export) & Strapi API
|
||||
- **Email**: Resend
|
||||
- **Analytics**: Vercel (consent-based)
|
||||
- **CAPTCHA**: Cloudflare Turnstile
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
@@ -108,7 +141,7 @@ app/
|
||||
├── api/
|
||||
│ └── contact/route.ts # Contact API
|
||||
├── sitemap.ts # Sitemap generator
|
||||
└── robots.ts # Robots.txt generator
|
||||
├── robots.ts # Robots.txt generator
|
||||
|
||||
lib/
|
||||
├── data.ts # Data access
|
||||
@@ -119,7 +152,7 @@ components/
|
||||
├── LocaleSwitcher.tsx # Language switcher
|
||||
├── ContactForm.tsx # Contact form
|
||||
├── CookieConsent.tsx # GDPR banner
|
||||
└── SEO.tsx # SEO utilities
|
||||
├── SEO.tsx # SEO utilities
|
||||
|
||||
data/
|
||||
├── raw/ # WordPress export
|
||||
@@ -138,6 +171,7 @@ scripts/
|
||||
## 🎯 Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||
- **Contact Forms**: Resend integration with validation
|
||||
- **GDPR Compliance**: Cookie consent banner
|
||||
@@ -150,12 +184,14 @@ scripts/
|
||||
- **Asset Management**: WordPress → local path mapping
|
||||
|
||||
### 🔄 In Progress
|
||||
|
||||
- Analytics integration (consent-based)
|
||||
- Turnstile CAPTCHA
|
||||
- Build testing
|
||||
- Deployment configuration
|
||||
|
||||
### 📝 Remaining
|
||||
|
||||
- Performance optimization
|
||||
- Final QA testing
|
||||
- Documentation updates
|
||||
@@ -163,6 +199,7 @@ scripts/
|
||||
## 📝 Content Management
|
||||
|
||||
### Data Export
|
||||
|
||||
```bash
|
||||
# Export from WordPress
|
||||
npm run data:export
|
||||
@@ -178,6 +215,7 @@ npm run data:improve-mapping
|
||||
```
|
||||
|
||||
### Adding New Content
|
||||
|
||||
1. Export new content from WordPress
|
||||
2. Process the data
|
||||
3. Rebuild the site
|
||||
@@ -185,17 +223,20 @@ npm run data:improve-mapping
|
||||
## 🎨 Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- Primary: `#0066cc` (KLZ Blue)
|
||||
- Secondary: `#00a896` (Teal)
|
||||
- Text: `#1a1a1a`
|
||||
- Background: `#f8f9fa`
|
||||
|
||||
### Typography
|
||||
|
||||
- Font: Inter
|
||||
- Base: 16px
|
||||
- Scale: 1.25 (Major Third)
|
||||
|
||||
### Layout
|
||||
|
||||
- Max width: 1200px
|
||||
- Responsive grid
|
||||
- Mobile-first
|
||||
@@ -203,6 +244,7 @@ npm run data:improve-mapping
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Contact Form
|
||||
|
||||
```
|
||||
POST /api/contact
|
||||
{
|
||||
@@ -214,65 +256,115 @@ POST /api/contact
|
||||
```
|
||||
|
||||
### Sitemap
|
||||
|
||||
```
|
||||
GET /sitemap.xml
|
||||
```
|
||||
|
||||
### Robots
|
||||
|
||||
```
|
||||
GET /robots.txt
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Vercel (Recommended)
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm i -g vercel
|
||||
### Automatic Deployment (Current Setup)
|
||||
|
||||
# Deploy
|
||||
vercel --prod
|
||||
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
|
||||
|
||||
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
|
||||
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
|
||||
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
|
||||
|
||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Branch Deployments**:
|
||||
|
||||
- `main` branch: Deploys to production using `.env.prod`
|
||||
- `staging` branch: Deploys to staging using `.env.staging`
|
||||
|
||||
**Environment Overrides**:
|
||||
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||
|
||||
**Required Secrets** (configure in Gitea repository settings):
|
||||
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
```bash
|
||||
# SSH into production server
|
||||
ssh deploy@alpha.mintel.me
|
||||
|
||||
# Navigate to project
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
|
||||
# Pull latest image and restart
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate --remove-orphans
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
### Static Export
|
||||
```bash
|
||||
# Build and export
|
||||
npm run build
|
||||
npm run export
|
||||
Or use the convenience script:
|
||||
|
||||
# Deploy to any static host
|
||||
# Upload /out directory
|
||||
```bash
|
||||
bash scripts/deploy-webhook.sh
|
||||
```
|
||||
|
||||
### Netlify
|
||||
```bash
|
||||
# Connect repository
|
||||
# Set build command: npm run build
|
||||
# Set publish directory: out
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Client → Traefik (TLS) → Next.js App
|
||||
```
|
||||
|
||||
**Domains**:
|
||||
|
||||
- `klz-cables.com` - Production
|
||||
- `www.klz-cables.com` - Production (www)
|
||||
- `staging.klz-cables.com` - Staging
|
||||
|
||||
**Services**:
|
||||
|
||||
- `app`: Next.js application (port 3000)
|
||||
- `traefik`: Reverse proxy (external)
|
||||
|
||||
For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
### Build Time
|
||||
|
||||
- **Target**: < 2 minutes
|
||||
- **Current**: ~1-2 minutes
|
||||
|
||||
### Page Load
|
||||
|
||||
- **Target**: < 100ms
|
||||
- **Current**: Static HTML from CDN
|
||||
|
||||
### Bundle Size
|
||||
|
||||
- **Target**: < 100KB gzipped
|
||||
- **Current**: Optimized with code splitting
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` file
|
||||
- Rotate keys regularly
|
||||
- Use secrets in deployment platform
|
||||
|
||||
### Form Security
|
||||
|
||||
- Email validation
|
||||
- Rate limiting (recommended)
|
||||
- Turnstile CAPTCHA (pending)
|
||||
@@ -280,6 +372,7 @@ npm run export
|
||||
## 🎓 WordPress Specifics
|
||||
|
||||
### WPBakery Shortcodes Removed
|
||||
|
||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||
- `[nectar_*]` (Salient theme)
|
||||
- `[image_with_animation]`
|
||||
@@ -287,13 +380,16 @@ npm run export
|
||||
- `[divider]`
|
||||
|
||||
### HTML Sanitization
|
||||
|
||||
- Removes inline event handlers
|
||||
- Strips scripts
|
||||
- Normalizes classes
|
||||
- Preserves structure
|
||||
|
||||
### Asset Mapping
|
||||
|
||||
WordPress URLs → Local paths:
|
||||
|
||||
```
|
||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
```
|
||||
@@ -301,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
## 📚 Documentation
|
||||
|
||||
### Internal
|
||||
|
||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||
- `FINAL_SUMMARY.md` - Complete overview
|
||||
|
||||
### External
|
||||
|
||||
- [Next.js Docs](https://nextjs.org/docs)
|
||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||
- [Resend Docs](https://resend.com/docs)
|
||||
@@ -316,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
### Common Issues
|
||||
|
||||
**TypeScript Errors**
|
||||
|
||||
- The TypeScript errors shown in the editor are expected
|
||||
- They occur because modules reference each other
|
||||
- The build process resolves these correctly
|
||||
- Run `npm run build` to verify
|
||||
|
||||
**Build Failures**
|
||||
|
||||
- Check environment variables
|
||||
- Verify data files exist
|
||||
- Clear `.next` cache: `rm -rf .next`
|
||||
|
||||
**Missing Modules**
|
||||
|
||||
- Run `npm install --legacy-peer-deps`
|
||||
- Check `package.json` dependencies
|
||||
|
||||
@@ -341,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
✅ **i18n**: Multi-language support
|
||||
✅ **SEO**: Metadata and sitemaps
|
||||
✅ **Compatibility**: WPBakery content handled
|
||||
✅ **Media**: All images downloaded
|
||||
✅ **Media**: All images downloaded
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the documentation
|
||||
2. Review the troubleshooting section
|
||||
3. Check environment variables
|
||||
@@ -360,3 +462,4 @@ Proprietary - KLZ Cables
|
||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: December 27, 2025
|
||||
Trigger rebuilding for x86 architecture.
|
||||
|
||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootPage } from '@payloadcms/next/views';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
84
app/(payload)/admin/importMap.js
Normal file
84
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
|
||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
|
||||
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
|
||||
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
|
||||
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import config from '@payload-config';
|
||||
import {
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_DELETE,
|
||||
} from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
4
app/(payload)/api/graphql/route.ts
Normal file
4
app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
151
app/(payload)/custom.scss
Normal file
151
app/(payload)/custom.scss
Normal file
@@ -0,0 +1,151 @@
|
||||
/* =================================================================
|
||||
KLZ Cables – Payload Admin Theme
|
||||
Strictly follows docs/STYLEGUIDE.md & tailwind.config.cjs
|
||||
|
||||
IMPORTANT: We use `html` selector (not `:root`) because Payload's
|
||||
own CSS defines variables on `:root` and loads AFTER this file.
|
||||
`html` has higher specificity than `:root`, so our values win.
|
||||
================================================================= */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
/* =================================================================
|
||||
COLOR OVERRIDES
|
||||
Payload internally maps:
|
||||
--theme-elevation-* → --color-base-*
|
||||
--theme-success-* → --color-success-*
|
||||
We override the SOURCE variables on `html` to beat Payload's `:root`.
|
||||
================================================================= */
|
||||
|
||||
html {
|
||||
/* ---------------------------------------------------------------
|
||||
KLZ Primary Blue (#011dff) → Buttons, links, active states
|
||||
--------------------------------------------------------------- */
|
||||
--color-success-50: #eef0ff !important;
|
||||
--color-success-100: #dfe2ff !important;
|
||||
--color-success-150: #cdd2ff !important;
|
||||
--color-success-200: #b8bfff !important;
|
||||
--color-success-250: #a0a9ff !important;
|
||||
--color-success-300: #8892ff !important;
|
||||
--color-success-350: #707bff !important;
|
||||
--color-success-400: #5564ff !important;
|
||||
--color-success-450: #3a4dff !important;
|
||||
--color-success-500: #011dff !important;
|
||||
/* KLZ Primary */
|
||||
--color-success-550: #0119e6 !important;
|
||||
--color-success-600: #0116cc !important;
|
||||
--color-success-650: #0112b3 !important;
|
||||
--color-success-700: #000e99 !important;
|
||||
--color-success-750: #000b80 !important;
|
||||
--color-success-800: #000866 !important;
|
||||
--color-success-850: #00054d !important;
|
||||
--color-success-900: #000333 !important;
|
||||
--color-success-950: #00011a !important;
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
KLZ "Foundation Neutrals" → Backgrounds, cards, borders, text
|
||||
Based on tailwind.config.cjs: neutral.light=#fff,
|
||||
neutral.DEFAULT=#f8f9fa, neutral.dark=#263336, neutral.black=#0a0a0a
|
||||
text.primary=#1a1a1a, text.secondary=#6c757d, text.light=#adb5bd
|
||||
--------------------------------------------------------------- */
|
||||
--color-base-0: #ffffff !important;
|
||||
--color-base-50: #f8f9fa !important;
|
||||
--color-base-100: #f1f3f5 !important;
|
||||
--color-base-150: #e9ecef !important;
|
||||
--color-base-200: #dee2e6 !important;
|
||||
--color-base-250: #ced4da !important;
|
||||
--color-base-300: #adb5bd !important;
|
||||
--color-base-350: #9ba3ab !important;
|
||||
--color-base-400: #868e96 !important;
|
||||
--color-base-450: #6c757d !important;
|
||||
--color-base-500: #5c636a !important;
|
||||
--color-base-550: #4d5358 !important;
|
||||
--color-base-600: #3d4246 !important;
|
||||
--color-base-650: #343a40 !important;
|
||||
--color-base-700: #2b3035 !important;
|
||||
--color-base-750: #263336 !important;
|
||||
--color-base-800: #212529 !important;
|
||||
--color-base-850: #1a1a1a !important;
|
||||
--color-base-900: #121212 !important;
|
||||
--color-base-950: #0a0a0a !important;
|
||||
--color-base-1000: #000000 !important;
|
||||
|
||||
/* Typography */
|
||||
--font-body: 'Inter', system-ui, -apple-system, sans-serif !important;
|
||||
--font-headings: 'Inter', system-ui, -apple-system, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Base Body Application */
|
||||
body {
|
||||
font-family: var(--font-body) !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Login / Setup Page
|
||||
================================================================= */
|
||||
.template-default.template-default--has-bg {
|
||||
background: radial-gradient(circle at top right, #e6ebf5 0%, #f8f9fa 60%, #f3f4f6 100%) !important;
|
||||
}
|
||||
|
||||
.login__wrap,
|
||||
.create-first-user__wrap {
|
||||
border-top: none !important;
|
||||
padding: 3rem !important;
|
||||
background: rgba(255, 255, 255, 0.85) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--theme-elevation-150) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
|
||||
border-radius: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Buttons – override Payload's dark buttons with KLZ Blue
|
||||
Payload uses .btn--style-primary { --bg-color: var(--theme-elevation-800) }
|
||||
which makes all primary buttons near-black. We override to KLZ Blue.
|
||||
================================================================= */
|
||||
.btn--style-primary,
|
||||
.btn--style-pill {
|
||||
--bg-color: #011dff !important;
|
||||
--color: #ffffff !important;
|
||||
--hover-bg: #0116cc !important;
|
||||
--hover-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn--style-primary.btn--disabled,
|
||||
.btn--style-pill.btn--disabled {
|
||||
--bg-color: #b8bfff !important;
|
||||
--color: #ffffff !important;
|
||||
--hover-bg: #b8bfff !important;
|
||||
}
|
||||
|
||||
/* Sidebar Active Items */
|
||||
[class*="nav-group__link--active"],
|
||||
[class*="nav__link--active"] {
|
||||
--theme-elevation-800: #011dff !important;
|
||||
color: #011dff !important;
|
||||
border-left-color: #011dff !important;
|
||||
}
|
||||
|
||||
.btn--style-secondary {
|
||||
--box-shadow: inset 0 0 0 1px #011dff !important;
|
||||
--color: #011dff !important;
|
||||
--hover-color: #0116cc !important;
|
||||
--hover-box-shadow: inset 0 0 0 1px #0116cc !important;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Logo & Icon
|
||||
================================================================= */
|
||||
.klz-admin-logo,
|
||||
.klz-admin-icon {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: 32px !important;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
31
app/(payload)/layout.tsx
Normal file
31
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { RootLayout } from '@payloadcms/next/layouts';
|
||||
import React from 'react';
|
||||
|
||||
import '@payloadcms/next/css';
|
||||
import './custom.scss';
|
||||
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: any = async function (args: any) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config: configPromise,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => {
|
||||
return (
|
||||
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
35
app/[locale]/[slug]/opengraph-image.tsx
Normal file
35
app/[locale]/[slug]/opengraph-image.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) {
|
||||
return new Response('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,60 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) return {};
|
||||
|
||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||
|
||||
// Determine correct localized slug based on current locale
|
||||
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||
|
||||
return {
|
||||
title: pageData.frontmatter.title,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${deSlug}`,
|
||||
en: `${SITE_URL}/en/${enSlug}`,
|
||||
'x-default': `${SITE_URL}/en/${enSlug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
||||
const { getPageBySlug } = await import('@/lib/pages');
|
||||
export default async function StandardPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
@@ -19,77 +62,82 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Redirect if accessed via a different locale's slug
|
||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||
if (correctSlug && correctSlug !== slug) {
|
||||
redirect(`/${locale}/${correctSlug}`);
|
||||
}
|
||||
|
||||
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
||||
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<PayloadRichText data={pageData.content} className="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default article layout with hero, content, and support CTA
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
||||
<Heading level={1} className="text-3xl md:text-6xl lg:text-7xl xl:text-8xl text-white mb-0">
|
||||
<span className="text-white">{pageData.frontmatter.title}</span>
|
||||
<div className="max-w-4xl">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
{t('badge')}
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-white -mt-8 md:-mt-12 relative z-20 rounded-t-[32px] md:rounded-t-[60px] shadow-2xl py-12 md:py-28">
|
||||
<Container>
|
||||
<div className="sticky-narrative-container">
|
||||
{/* Sticky Narrative Sidebar - Mobile Optimized */}
|
||||
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
||||
<div className="lg:sticky lg:top-32 space-y-4 md:space-y-8">
|
||||
{/* Mobile-only chip/stepper feel */}
|
||||
<div className="flex lg:hidden overflow-x-auto pb-4 gap-3 no-scrollbar -mx-4 px-4">
|
||||
<Badge variant="primary" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm">{t('overview')}</Badge>
|
||||
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('details')}</Badge>
|
||||
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('support')}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 bg-neutral-light rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm">
|
||||
<h3 className="text-lg md:text-xl font-bold text-primary mb-3 md:mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-accent rounded-full" />
|
||||
{t('quickNavigation')}
|
||||
</h3>
|
||||
<nav className="space-y-3 md:space-y-4">
|
||||
<p className="text-sm md:text-base text-text-secondary leading-relaxed">
|
||||
{t('exploreDetails', { title: pageData.frontmatter.title })}
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 bg-primary-dark rounded-2xl md:rounded-3xl text-white shadow-xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-24 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 relative z-10">{t('needHelp')}</h3>
|
||||
<p className="text-sm md:text-base text-white/70 mb-4 md:mb-6 relative z-10">{t('supportTeamAvailable')}</p>
|
||||
<a href={`/${locale}/contact`} className="inline-flex items-center text-accent font-bold hover:underline touch-target relative z-10 group/link">
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Content Area */}
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{pageData.frontmatter.excerpt && (
|
||||
<div className="mb-16">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||
{pageData.frontmatter.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="sticky-narrative-content">
|
||||
<article className="prose prose-sm md:prose-lg lg:prose-xl prose-primary max-w-none
|
||||
prose-headings:text-primary prose-headings:font-bold prose-headings:tracking-tight
|
||||
prose-p:text-text-secondary prose-p:leading-relaxed
|
||||
prose-strong:text-primary prose-strong:font-extrabold
|
||||
prose-a:text-primary prose-a:font-bold prose-a:no-underline hover:prose-a:underline
|
||||
prose-img:rounded-2xl md:prose-img:rounded-3xl prose-img:shadow-2xl
|
||||
prose-ul:list-disc prose-ul:pl-5 md:prose-ul:pl-6
|
||||
prose-li:text-text-secondary
|
||||
">
|
||||
<MDXRemote source={pageData.content} />
|
||||
</article>
|
||||
{/* Main content with shared blog components */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||
<PayloadRichText data={pageData.content} />
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||
eventProperties={{
|
||||
location: 'generic_page_support_cta',
|
||||
page_slug: slug,
|
||||
}}
|
||||
>
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
app/[locale]/api/og/product/route.tsx
Normal file
78
app/[locale]/api/og/product/route.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/products';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const { locale } = await params;
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
if (categories.includes(slug)) {
|
||||
const categoryKey = slug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: slug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||
? t(`categories.${categoryKey}.description`)
|
||||
: '';
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new Response('Product not found', { status: 404 });
|
||||
}
|
||||
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,113 +1,68 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||
async function fetchImageAsBase64(url: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return undefined;
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OG image:', url, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
return new Response('Post not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||
// For local images during dev, relative paths in <img> might not work in Satori
|
||||
// but if we are in nodejs runtime, we could potentially read from disk.
|
||||
// For now, let's just make sure it's absolute.
|
||||
const featuredImage = post.frontmatter.featuredImage
|
||||
? post.frontmatter.featuredImage.startsWith('http')
|
||||
? post.frontmatter.featuredImage
|
||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined;
|
||||
|
||||
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||
let base64Image: string | undefined = undefined;
|
||||
if (featuredImage) {
|
||||
base64Image = await fetchImageAsBase64(featuredImage);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: '#001a4d',
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background Image Overlay if available */}
|
||||
{post.frontmatter.featuredImage && (
|
||||
<img
|
||||
src={post.frontmatter.featuredImage.startsWith('http') ? post.frontmatter.featuredImage : `https://klz-cables.com${post.frontmatter.featuredImage}`}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,26,77,1) 0%, rgba(0,26,77,0.4) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{post.frontmatter.category && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#00ff99',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: 'rgba(0,255,153,0.1)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{post.frontmatter.category}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
fontWeight: '900',
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
KLZ Cables Blog
|
||||
</div>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#00ff99', margin: '0 16px' }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={base64Image || featuredImage}
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,334 +1,317 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getPostBySlug, getAdjacentPosts } from '@/lib/blog';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import {
|
||||
getPostBySlug,
|
||||
getAdjacentPosts,
|
||||
getReadingTime,
|
||||
extractLexicalHeadings,
|
||||
} from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||
|
||||
// Payload CMS Imports
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: post.frontmatter.title,
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
description: description,
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.frontmatter.title,
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
description: description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import Link from 'next/link';
|
||||
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
||||
import Callout from '@/components/blog/Callout';
|
||||
import HighlightBox from '@/components/blog/HighlightBox';
|
||||
import Stats from '@/components/blog/Stats';
|
||||
import AnimatedImage from '@/components/blog/AnimatedImage';
|
||||
import ChatBubble from '@/components/blog/ChatBubble';
|
||||
import SplitHeading from '@/components/blog/SplitHeading';
|
||||
import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import ShareButton from '@/components/blog/ShareButton';
|
||||
|
||||
const components = {
|
||||
VisualLinkPreview,
|
||||
Callout,
|
||||
HighlightBox,
|
||||
Stats,
|
||||
AnimatedImage,
|
||||
ChatBubble,
|
||||
PowerCTA,
|
||||
SplitHeading,
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
if (href?.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
|
||||
>
|
||||
{children}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: (props: any) => (
|
||||
<AnimatedImage src={props.src} alt={props.alt} />
|
||||
),
|
||||
h2: ({ children, ...props }: any) => (
|
||||
<SplitHeading {...props} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
||||
{children}
|
||||
</SplitHeading>
|
||||
),
|
||||
h3: ({ children, ...props }: any) => (
|
||||
<h3 {...props} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul {...props} className="my-8 space-y-3">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }: any) => (
|
||||
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: any) => (
|
||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
||||
<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>
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
||||
<div className="text-lg text-text-primary italic">
|
||||
{children}
|
||||
</div>
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong {...props} className="font-bold text-primary">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
code: ({ children, ...props }: any) => (
|
||||
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children, ...props }: any) => (
|
||||
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }: any) => (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
|
||||
<table {...props} className="w-full text-left text-sm text-text-secondary">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }: any) => (
|
||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children, ...props }: any) => (
|
||||
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }: any) => (
|
||||
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children, ...props }: any) => (
|
||||
<th {...props} className="px-6 py-4 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }: any) => (
|
||||
<td {...props} className="px-6 py-4">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
||||
export default async function BlogPost({ params }: BlogPostProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// If the user accessed this post using a slug from a different locale
|
||||
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
|
||||
if (post.slug && post.slug !== slug) {
|
||||
redirect(`/${locale}/blog/${post.slug}`);
|
||||
}
|
||||
|
||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||
|
||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||
// Extract headings for TOC
|
||||
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||
|
||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||
const rawTextContent = JSON.stringify(post.content);
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen font-sans">
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
<BlogEngagementTracker
|
||||
title={post.frontmatter.title}
|
||||
slug={slug}
|
||||
category={post.frontmatter.category}
|
||||
readingTime={getReadingTime(rawTextContent)}
|
||||
/>
|
||||
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative w-full h-[60vh] min-h-[400px] overflow-hidden group">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[2s] ease-out scale-105 group-hover:scale-100"
|
||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
||||
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
fill
|
||||
priority
|
||||
quality={100}
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
style={{
|
||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||
|
||||
{/* Title overlay on image */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-8 md:p-16 lg:p-24">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
{post.frontmatter.category && (
|
||||
<span className="inline-block px-4 py-1.5 bg-primary/90 backdrop-blur-sm text-white text-sm font-bold uppercase tracking-wider rounded-full mb-6 shadow-lg transform transition-transform hover:scale-105">
|
||||
{post.frontmatter.category}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white mb-6 leading-tight drop-shadow-xl">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 text-white/90 text-sm md:text-base font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
|
||||
<span>KLZ Cables</span>
|
||||
<div className="ml-auto hidden md:block">
|
||||
<ShareButton
|
||||
title={post.frontmatter.title}
|
||||
text={post.frontmatter.excerpt || ''}
|
||||
url={`https://klz-cables.com/${locale}/blog/${slug}`}
|
||||
locale={locale}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl">
|
||||
{post.frontmatter.category && (
|
||||
<div className="overflow-hidden mb-6">
|
||||
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||
{post.frontmatter.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||
{post.frontmatter.title}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||
Draft Preview
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="container mx-auto px-4 py-16 md:py-24 max-w-3xl">
|
||||
{/* Mobile Share Button */}
|
||||
<div className="md:hidden mb-8 flex justify-end">
|
||||
<ShareButton
|
||||
title={post.frontmatter.title}
|
||||
text={post.frontmatter.excerpt || ''}
|
||||
url={`https://klz-cables.com/${locale}/blog/${slug}`}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
{/* If no featured image, show header here */}
|
||||
{!post.frontmatter.featuredImage && (
|
||||
<header className="mb-16 text-center">
|
||||
) : (
|
||||
<header className="pt-32 pb-16 bg-neutral-50 border-b border-neutral-100">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
{post.frontmatter.category && (
|
||||
<div className="mb-6">
|
||||
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-sm font-bold uppercase tracking-wider rounded-full">
|
||||
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
||||
{post.frontmatter.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-text-primary mb-8 leading-tight">
|
||||
<Heading level={1} className="mb-8">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-4 text-text-secondary font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
|
||||
<span>KLZ Cables</span>
|
||||
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||
Draft Preview
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{post.frontmatter.excerpt && (
|
||||
<div className="mb-16">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-6 py-2">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main content with enhanced styling */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl prose-img:shadow-lg">
|
||||
<MDXRemote source={post.content} components={components} />
|
||||
</div>
|
||||
{/* Main Content Area with Sticky Narrative Layout */}
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="sticky-narrative-container">
|
||||
{/* Left Column: Content */}
|
||||
<div className="sticky-narrative-content">
|
||||
{/* Excerpt/Lead paragraph if available */}
|
||||
{post.frontmatter.excerpt && (
|
||||
<div className="mb-16">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured Data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://klz-cables.com/logo.png', // Assuming logo exists
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{/* Main content with enhanced styling rendering Payload Lexical */}
|
||||
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
|
||||
<PayloadRichText data={post.content} />
|
||||
</div>
|
||||
|
||||
{/* Power CTA */}
|
||||
<div className="mt-20">
|
||||
<PowerCTA locale={locale} />
|
||||
</div>
|
||||
{/* Power CTA */}
|
||||
<div className="mt-24 animate-slight-fade-in-from-bottom">
|
||||
<PowerCTA locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* Post Navigation */}
|
||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
||||
{/* Post Navigation */}
|
||||
<div className="mt-16">
|
||||
<PostNavigation
|
||||
prev={prev}
|
||||
next={next}
|
||||
isPrevRandom={isPrevRandom}
|
||||
isNextRandom={isNextRandom}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Back to blog link */}
|
||||
<div className="mt-16 pt-10 border-t border-neutral-200 text-center">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center gap-2 text-text-secondary hover:text-primary font-medium text-lg transition-colors group"
|
||||
>
|
||||
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||
</Link>
|
||||
{/* Back to blog link */}
|
||||
<div className="mt-16 pt-10 border-t border-neutral-100">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 transition-transform group-hover:-translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sticky Sidebar - TOC */}
|
||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||
<div className="space-y-12 lg:sticky lg:top-32">
|
||||
<TableOfContents headings={headings} locale={locale} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structured Data */}
|
||||
<JsonLd
|
||||
id={`jsonld-${slug}`}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage
|
||||
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: rawTextContent.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
<JsonLd
|
||||
id={`breadcrumb-${slug}`}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `${SITE_URL}/${locale}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.frontmatter.title,
|
||||
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
],
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
22
app/[locale]/blog/opengraph-image.tsx
Normal file
22
app/[locale]/blog/opengraph-image.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,55 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/blog`,
|
||||
en: `${SITE_URL}/en/blog`,
|
||||
'x-default': `${SITE_URL}/en/blog`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `${SITE_URL}/${locale}/blog`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
|
||||
|
||||
// Sort posts by date descending
|
||||
const sortedPosts = [...posts].sort((a, b) =>
|
||||
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
||||
const sortedPosts = [...posts].sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
@@ -33,38 +58,62 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
return (
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
{/* Hero Section - Immersive Magazine Feel */}
|
||||
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<img
|
||||
src={featuredPost.frontmatter.featuredImage}
|
||||
alt={featuredPost.frontmatter.title}
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<h1 className="text-3xl md:text-7xl font-extrabold text-white mb-4 md:mb-8 leading-[1.1] line-clamp-3 md:line-clamp-none">
|
||||
{featuredPost.frontmatter.title}
|
||||
</h1>
|
||||
<p className="text-base md:text-2xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</p>
|
||||
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
||||
{t('readFullArticle')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
<Reveal>
|
||||
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={featuredPost.frontmatter.title}
|
||||
fill
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||
style={{
|
||||
objectPosition: `${featuredPost.frontmatter.focalX ?? 50}% ${featuredPost.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-neutral-dark/20" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||
{featuredPost &&
|
||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||
featuredPost.frontmatter.public === false) && (
|
||||
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||
Draft Preview
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</p>
|
||||
<Button
|
||||
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||
>
|
||||
{t('readFullArticle')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</article>
|
||||
</Reveal>
|
||||
|
||||
<Section className="bg-neutral-light py-12 md:py-28">
|
||||
<Container>
|
||||
@@ -75,56 +124,115 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* Category filters could go here */}
|
||||
<Badge variant="primary" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.all')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.industry')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.technical')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.sustainability')}</Badge>
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Grid for remaining posts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
||||
<div className="grid grid-cols-1 gap-12">
|
||||
{remainingPosts.map((post, idx) => (
|
||||
<Reveal key={post.slug} delay={idx * 100}>
|
||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
||||
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
|
||||
<Reveal key={post.slug} delay={idx * 50}>
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
className="group block focus:outline-none"
|
||||
>
|
||||
<Card
|
||||
tag="article"
|
||||
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
|
||||
>
|
||||
{post.frontmatter.featuredImage && (
|
||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
||||
<img
|
||||
src={post.frontmatter.featuredImage}
|
||||
<>
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
alt={post.frontmatter.title}
|
||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
fill
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
style={{
|
||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||
}}
|
||||
sizes="(max-width: 768px) 100vw, 100vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg">
|
||||
<Badge variant="accent" className="shadow-md">
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(new Date(post.frontmatter.date) > new Date() ||
|
||||
post.frontmatter.public === false) && (
|
||||
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||
Draft Preview
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
||||
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
||||
|
||||
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
||||
<span className="text-primary text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
||||
|
||||
{post.frontmatter.excerpt && (
|
||||
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
|
||||
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
|
||||
{t('readMore')}
|
||||
</span>
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
|
||||
<svg
|
||||
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,14 +242,48 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Placeholder */}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
|
||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
|
||||
<Button
|
||||
href="#"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base pointer-events-none opacity-50"
|
||||
aria-disabled="true"
|
||||
aria-keyshortcuts="ArrowLeft"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=1`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
aria-current="page"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
>
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
aria-keyshortcuts="ArrowRight"
|
||||
>
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
25
app/[locale]/contact/opengraph-image.tsx
Normal file
25
app/[locale]/contact/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,147 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Button, Heading, Card } from '@/components/ui';
|
||||
import ContactForm from '@/components/ContactForm';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export default function ContactPage() {
|
||||
const t = useTranslations('Contact');
|
||||
import { Suspense } from 'react';
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/kontakt`,
|
||||
en: `${SITE_URL}/en/contact`,
|
||||
'x-default': `${SITE_URL}/en/contact`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
|
||||
siteName: 'KLZ Cables',
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ locale: 'de' }, { locale: 'en' }];
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
|
||||
// Get translated slug to redirect if user used incorrect static slug
|
||||
const { headers } = await import('next/headers');
|
||||
const headersList = await headers();
|
||||
const urlPath = headersList.get('x-invoke-path') || '';
|
||||
const currentSlug = urlPath.split('/').pop();
|
||||
|
||||
if (currentSlug) {
|
||||
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
|
||||
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
|
||||
const { redirect } = await import('next/navigation');
|
||||
redirect(`/${locale}/${contactSlugDe}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
id="breadcrumb-contact"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('title'),
|
||||
item: `${SITE_URL}/${locale}/contact`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<JsonLd
|
||||
id="local-business-contact"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'KLZ Cables',
|
||||
image: `${SITE_URL}/logo.png`,
|
||||
'@id': SITE_URL,
|
||||
url: SITE_URL,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Raiffeisenstraße 22',
|
||||
addressLocality: 'Remshalden',
|
||||
postalCode: '73630',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: 48.8144,
|
||||
longitude: 9.4144,
|
||||
},
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
'@type': 'OpeningHoursSpecification',
|
||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||
opens: '08:00',
|
||||
closes: '17:00',
|
||||
},
|
||||
],
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Heading level={1} subtitle={t('heroSubtitle')} className="text-white mb-4 md:mb-6">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-2xl text-white/70 leading-relaxed max-w-2xl">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<Reveal>
|
||||
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl">
|
||||
<Heading level={1} subtitle={t('heroSubtitle')} className="text-white mb-4 md:mb-6">
|
||||
<span className="text-white">{t('title')}</span>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
<Section className="bg-neutral-light -mt-8 md:-mt-20 relative z-20 py-12 md:py-28">
|
||||
<Container>
|
||||
@@ -32,16 +152,33 @@ export default function ContactPage() {
|
||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||
{t('info.howToReachUs')}
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<address className="space-y-4 md:space-y-8 not-italic">
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 md:w-7 md:h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.office')}</h4>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.office')}
|
||||
</h4>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{t('info.address')}
|
||||
</p>
|
||||
@@ -49,34 +186,39 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 md:w-7 md:h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
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>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.phone')}</h4>
|
||||
<a href="tel:+4988192537298" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">+49 881 92537298</a>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.email')}
|
||||
</h4>
|
||||
<ObfuscatedEmail
|
||||
email="info@klz-cables.com"
|
||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
|
||||
<a href="mailto:info@klz-vertriebs-gmbh.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-vertriebs-gmbh.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||
<Heading level={4} className="mb-4 md:mb-6">{t('hours.title')}</Heading>
|
||||
<ul className="space-y-2 md:space-y-4">
|
||||
<Heading level={4} className="mb-4 md:mb-6">
|
||||
{t('hours.title')}
|
||||
</Heading>
|
||||
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
||||
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
||||
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
||||
<span className="text-text-secondary">{t('hours.weekdaysTime')}</span>
|
||||
@@ -91,74 +233,29 @@ export default function ContactPage() {
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7">
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
{t('form.title')}
|
||||
</Heading>
|
||||
<form className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<label htmlFor="name" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<label htmlFor="email" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<label htmlFor="message" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.message')}</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg resize-none"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button type="submit" size="lg" className="w-full shadow-xl shadow-primary/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform">
|
||||
{t('form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||
}
|
||||
>
|
||||
<ContactForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
{/* Map Placeholder */}
|
||||
|
||||
{/* Map Section */}
|
||||
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 bg-primary rounded-full flex items-center justify-center text-white mb-4 mx-auto shadow-2xl animate-bounce">
|
||||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
</svg>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||
<div className="text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
<p className="font-bold text-primary text-xl">{t('map.comingSoon')}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
67
app/[locale]/error.tsx
Normal file
67
app/[locale]/error.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations('Error');
|
||||
|
||||
useEffect(() => {
|
||||
// Treat "Failed to find Server Action" as a deployment sync issue and reload
|
||||
if (error?.message?.includes('Failed to find Server Action')) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
const services = getAppServices();
|
||||
services.errors.captureException(error);
|
||||
services.logger.error('Application error caught by boundary', {
|
||||
message: error?.message || 'Unknown error',
|
||||
stack: error?.stack,
|
||||
digest: error?.digest,
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">500</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||
500
|
||||
</Heading>
|
||||
<Scribble variant="underline" className="w-full h-6 -bottom-2 left-0 text-saturated/40" />
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||
{t('tryAgain')}
|
||||
</Button>
|
||||
<Button href="/" variant="outline" size="lg">
|
||||
{t('goHome')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Industrial Line */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-b from-saturated/50 to-transparent" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,51 @@
|
||||
import {NextIntlClientProvider} from 'next-intl';
|
||||
import {getMessages, getTranslations} from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import UmamiScript from '@/components/analytics/UmamiScript';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import SkipLink from '@/components/SkipLink';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
export async function generateMetadata({params: {locale}}: {params: {locale: string}}): Promise<Metadata> {
|
||||
const t = await getTranslations({locale, namespace: 'Index.meta'});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
|
||||
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||
return {
|
||||
title: {
|
||||
default: t('title'),
|
||||
template: `%s | ${t('title')}`
|
||||
template: '%s | KLZ Cables',
|
||||
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
|
||||
},
|
||||
description: t('description'),
|
||||
metadataBase: new URL('https://klz-cables.com'),
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
canonical: `${baseUrl}/${locale}`,
|
||||
languages: {
|
||||
'de-DE': '/de',
|
||||
'en-US': '/en',
|
||||
de: `${baseUrl}/de`,
|
||||
en: `${baseUrl}/en`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: locale === 'de' ? 'de_DE' : 'en_US',
|
||||
url: `https://klz-cables.com/${locale}`,
|
||||
siteName: 'KLZ Cables',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -54,39 +53,116 @@ export async function generateMetadata({params: {locale}}: {params: {locale: str
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: {locale}
|
||||
}: {
|
||||
|
||||
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: {locale: string};
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Providing all messages to the client
|
||||
// side is the easiest way to get started
|
||||
const messages = await getMessages();
|
||||
|
||||
const params = await props.params;
|
||||
const { locale } = params;
|
||||
const { children } = props;
|
||||
const supportedLocales = ['en', 'de'];
|
||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||
|
||||
setRequestLocale(safeLocale);
|
||||
|
||||
let messages: Record<string, any> = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch {
|
||||
messages = {};
|
||||
}
|
||||
|
||||
// Pick only the namespaces required by client components to reduce the hydration payload size
|
||||
const clientKeys = [
|
||||
'Footer',
|
||||
'Navigation',
|
||||
'Contact',
|
||||
'Products',
|
||||
'Team',
|
||||
'Home',
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
if (messages[key]) {
|
||||
clientMessages[key] = messages[key];
|
||||
}
|
||||
}
|
||||
|
||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||
const serverServices = getServerAppServices();
|
||||
|
||||
try {
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
// Disable analytics in CI to prevent console noise/score penalties
|
||||
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||
// Skip setting server context for analytics in CI
|
||||
} else if ('setServerContext' in serverServices.analytics) {
|
||||
(serverServices.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Server-side analytics tracking removed to prevent duplicate/empty events.
|
||||
// Client-side AnalyticsProvider handles all pageviews.
|
||||
} catch {
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.warn(
|
||||
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
||||
<html
|
||||
lang={safeLocale}
|
||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||
</head>
|
||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
|
||||
<AutoBrochureModal />
|
||||
</NextIntlClientProvider>
|
||||
|
||||
{/* Loads Umami only when NEXT_PUBLIC_UMAMI_WEBSITE_ID is set */}
|
||||
<UmamiScript />
|
||||
|
||||
{/* Sends pageviews for client-side navigations */}
|
||||
<AnalyticsProvider />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
137
app/[locale]/not-found.tsx
Normal file
137
app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { headers } from 'next/headers';
|
||||
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations('Error.notFound');
|
||||
|
||||
// Try to determine the requested path
|
||||
const headersList = await headers();
|
||||
const urlPath = headersList.get('x-invoke-path') || '';
|
||||
|
||||
let suggestedUrl = null;
|
||||
let suggestedLang = null;
|
||||
|
||||
// If we have a path, try to see if the last segment (slug) exists in ANY locale
|
||||
if (urlPath) {
|
||||
const slug = urlPath.split('/').filter(Boolean).pop();
|
||||
if (slug) {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Check posts
|
||||
const postRes = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
// Check products
|
||||
const productRes =
|
||||
postRes.docs.length === 0
|
||||
? await payload.find({
|
||||
collection: 'products',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all',
|
||||
limit: 1,
|
||||
})
|
||||
: { docs: [] };
|
||||
|
||||
// Check pages
|
||||
const pageRes =
|
||||
postRes.docs.length === 0 && productRes.docs.length === 0
|
||||
? await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slug } },
|
||||
locale: 'all',
|
||||
limit: 1,
|
||||
})
|
||||
: { docs: [] };
|
||||
|
||||
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
|
||||
|
||||
if (anyDoc) {
|
||||
// If the doc exists, we can figure out its native locale or
|
||||
// offer the alternative locale (if we are in 'de', offer 'en')
|
||||
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
|
||||
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||
|
||||
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
|
||||
|
||||
// Reconstruct the URL for the alternative locale
|
||||
const pathParts = urlPath.split('/').filter(Boolean);
|
||||
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
|
||||
pathParts[0] = alternativeLocale;
|
||||
} else {
|
||||
pathParts.unshift(alternativeLocale);
|
||||
}
|
||||
suggestedUrl = '/' + pathParts.join('/');
|
||||
}
|
||||
} catch {
|
||||
// Ignore Payload errors in 404
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClientNotFoundTracker path={urlPath} />
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||
|
||||
{suggestedUrl && (
|
||||
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-primary font-bold mb-2 text-lg">
|
||||
Did you mean to visit the {suggestedLang} version?
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm mb-4">
|
||||
This page exists, but in another language.
|
||||
</p>
|
||||
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
|
||||
Go to {suggestedLang} Version
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Industrial Line */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +1,26 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#001a4d', // Primary Blue from Styleguide
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background Pattern / Scribble placeholder */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100px',
|
||||
right: '-100px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(0,255,153,0.1) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#00ff99', // Accent Green
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: '900',
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '800px',
|
||||
marginBottom: '30px',
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '700px',
|
||||
}}
|
||||
>
|
||||
{t('description')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Accent Line */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
backgroundColor: '#00ff99',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,117 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Hero from '@/components/home/Hero';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||
import RecentPosts from '@/components/home/RecentPosts';
|
||||
import Experience from '@/components/home/Experience';
|
||||
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
||||
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Reveal from '@/components/Reveal';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
|
||||
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
|
||||
|
||||
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
|
||||
const Experience = dynamic(() => import('@/components/home/Experience'));
|
||||
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
|
||||
const MeetTheTeam = dynamic(() => import('@/components/home/MeetTheTeam'));
|
||||
const GallerySection = dynamic(() => import('@/components/home/GallerySection'));
|
||||
const VideoSection = dynamic(() => import('@/components/home/VideoSection'));
|
||||
const CTA = dynamic(() => import('@/components/home/CTA'));
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<JsonLd
|
||||
id="breadcrumb-home"
|
||||
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||
/>
|
||||
{/*
|
||||
The instruction refers to changing a class within the Hero component's paragraph.
|
||||
Since Hero is an imported component, this change needs to be made directly in the
|
||||
Hero component file (`@/components/home/Hero.tsx`) itself, not in this page file.
|
||||
This file (`app/[locale]/page.tsx`) only renders the Hero component.
|
||||
Therefore, no change is applied here.
|
||||
*/}
|
||||
<Hero />
|
||||
<Reveal><ProductCategories /></Reveal>
|
||||
<Reveal><WhatWeDo /></Reveal>
|
||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
||||
<Reveal><Experience /></Reveal>
|
||||
<Reveal><WhyChooseUs /></Reveal>
|
||||
<Reveal><MeetTheTeam /></Reveal>
|
||||
<Reveal><GallerySection /></Reveal>
|
||||
<Reveal><VideoSection /></Reveal>
|
||||
<Reveal><CTA /></Reveal>
|
||||
<Reveal>
|
||||
<ProductCategories />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhatWeDo />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<RecentPosts locale={locale} />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<Experience />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhyChooseUs />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<MeetTheTeam />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<GallerySection />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<VideoSection />
|
||||
</Reveal>
|
||||
<Reveal className="content-visibility-auto">
|
||||
<CTA />
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
// Use translations for meta where available (namespace: Index.meta)
|
||||
// Fallback to a sensible default if translation keys are missing.
|
||||
let t;
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
} catch {
|
||||
// If translations for Index.meta are not present, try generic Index namespace
|
||||
try {
|
||||
t = await getTranslations({ locale, namespace: 'Index' });
|
||||
} catch {
|
||||
t = () => '';
|
||||
}
|
||||
}
|
||||
|
||||
const title = t('title') || 'KLZ Cables';
|
||||
const description =
|
||||
t('description') ||
|
||||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de`,
|
||||
en: `${SITE_URL}/en`,
|
||||
'x-default': `${SITE_URL}/en`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}`,
|
||||
images: getOGImageMetadata('', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
563
app/[locale]/products/[...slug]/page.tsx
Normal file
563
app/[locale]/products/[...slug]/page.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||
import PayloadRichText from '@/components/PayloadRichText';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
if (categories.includes(fileSlug)) {
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||
? t(`categories.${categoryKey}.description`)
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||
const getLocalizedPath = async (lang: string) => {
|
||||
const parts = await Promise.all([
|
||||
mapFileSlugToTranslated('products', lang),
|
||||
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
|
||||
]);
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
if (!product) return {};
|
||||
|
||||
const currentLocalePath = await getLocalizedPath(locale);
|
||||
|
||||
return {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
|
||||
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||
const translatedSlugsForLocale = await Promise.all(
|
||||
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
|
||||
);
|
||||
|
||||
// If the requested slugs don't exactly match the translated slugs for the current locale
|
||||
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
|
||||
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
|
||||
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
|
||||
}
|
||||
|
||||
const fileSlug = fileSlugs[fileSlugs.length - 1];
|
||||
|
||||
if (categories.includes(fileSlug)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
|
||||
const filteredProducts = allProducts.filter((p) => {
|
||||
const firstCat = p.frontmatter.categories[0] || '';
|
||||
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||
let pFileSlug = 'low-voltage-cables';
|
||||
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||
pFileSlug = 'high-voltage-cables';
|
||||
else if (
|
||||
normalizedCat === 'mittelspannungskabel' ||
|
||||
normalizedCat === 'medium-voltage-cables'
|
||||
)
|
||||
pFileSlug = 'medium-voltage-cables';
|
||||
else if (
|
||||
normalizedCat === 'solarkabel' ||
|
||||
normalizedCat === 'solar-cables' ||
|
||||
normalizedCat === 'solar'
|
||||
)
|
||||
pFileSlug = 'solar-cables';
|
||||
|
||||
return pFileSlug === fileSlug;
|
||||
});
|
||||
|
||||
const productsWithTranslatedSlugs = await Promise.all(
|
||||
filteredProducts.map(async (p) => ({
|
||||
...p,
|
||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||
<Link
|
||||
href={`/${locale}/${productsSlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
>
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||
</Link>
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
</nav>
|
||||
<Heading level={1} className="text-white mb-8">
|
||||
{categoryTitle}
|
||||
</Heading>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-neutral-light relative">
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{productsWithTranslatedSlugs.map((product) => (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/${locale}/${productsSlug}/${productSlug}/${product.translatedSlug}`}
|
||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||
>
|
||||
<Card tag="article" className="premium-card-reset">
|
||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
|
||||
if (!product) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Extract technical data natively from the Lexical AST for Schema.org
|
||||
let technicalItems = [];
|
||||
if (product.content?.root?.children) {
|
||||
const productTabsBlock = product.content.root.children.find(
|
||||
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
|
||||
technicalItems = productTabsBlock.fields.technicalItems;
|
||||
}
|
||||
}
|
||||
|
||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
const categoryKey = categoryFileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: categoryFileSlug;
|
||||
|
||||
// Split content into Description and Technical Data
|
||||
const rootChildren = product.content?.root?.children || [];
|
||||
const technicalBlocks = rootChildren.filter(
|
||||
(node: any) =>
|
||||
node.type === 'block' &&
|
||||
(node.fields?.blockType === 'productTabs' ||
|
||||
node.fields?.blockType === 'productTechnicalData'),
|
||||
);
|
||||
let descriptionChildren = rootChildren.filter(
|
||||
(node: any) =>
|
||||
!(
|
||||
node.type === 'block' &&
|
||||
(node.fields?.blockType === 'productTabs' ||
|
||||
node.fields?.blockType === 'productTechnicalData')
|
||||
),
|
||||
);
|
||||
|
||||
// If no standalone description nodes, extract from the productTabs block's embedded content
|
||||
if (descriptionChildren.length === 0) {
|
||||
const tabsBlock = rootChildren.find(
|
||||
(node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
|
||||
);
|
||||
if (tabsBlock?.fields?.content?.root?.children) {
|
||||
descriptionChildren = tabsBlock.fields.content.root.children.filter((node: any) => {
|
||||
// Filter out MDX parsing artifacts like `}>`
|
||||
if (node.type === 'paragraph' && node.children?.length === 1) {
|
||||
const text = node.children[0]?.text?.trim();
|
||||
return text !== '}>' && text !== '{' && text !== '}';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||
|
||||
const descriptionContent = {
|
||||
root: {
|
||||
...product.content.root,
|
||||
children: descriptionChildren,
|
||||
},
|
||||
};
|
||||
|
||||
const technicalContent = {
|
||||
root: {
|
||||
...product.content.root,
|
||||
children: technicalBlocks,
|
||||
},
|
||||
};
|
||||
|
||||
const sidebar = (
|
||||
<ProductSidebar
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
excelPath={excelPath}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white relative">
|
||||
{/* Product Hero */}
|
||||
<ProductEngagementTracker
|
||||
productName={product.frontmatter.title}
|
||||
productSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
sku={product.frontmatter.sku}
|
||||
/>
|
||||
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
{/* Background Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<Link
|
||||
href={`/${locale}/${productsSlug}`}
|
||||
className="hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||
</Link>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<Link
|
||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||
>
|
||||
{categoryTitle}
|
||||
</Link>
|
||||
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||
{product.frontmatter.title}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||
<div className="flex-1">
|
||||
{isFallback && (
|
||||
<div className="mb-8 inline-flex items-center px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-[10px] font-black uppercase tracking-[0.2em] backdrop-blur-md">
|
||||
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse" />
|
||||
{t('englishVersion')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||
{product.frontmatter.categories.map((cat, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="accent"
|
||||
className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]"
|
||||
>
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||
{product.frontmatter.title}
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-white relative">
|
||||
<Container className="relative">
|
||||
{/* Large Product Image Section */}
|
||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||
<div
|
||||
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||
</div>
|
||||
|
||||
{product.frontmatter.images.length > 1 && (
|
||||
<div className="flex justify-center gap-8 mt-20">
|
||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt=""
|
||||
fill
|
||||
sizes="128px"
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||
{/* Description Area Next to Sidebar */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||
{descriptionChildren.length > 0 ? (
|
||||
<PayloadRichText data={descriptionContent} />
|
||||
) : product.frontmatter.description ? (
|
||||
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{product.application?.root?.children?.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<PayloadRichText data={product.application} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<div className="lg:col-span-4 lg:sticky lg:top-32 h-fit">{sidebar}</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width Technical Data Below */}
|
||||
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||
<PayloadRichText data={technicalContent} />
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download Section */}
|
||||
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||
{t('downloadDatasheet')}
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl">
|
||||
<DatasheetDownload
|
||||
datasheetPath={datasheetPath}
|
||||
className="mt-0 w-full sm:w-auto"
|
||||
/>
|
||||
{excelPath && (
|
||||
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured Data (Hidden) */}
|
||||
<JsonLd
|
||||
id={`jsonld-${product.slug}`}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0]
|
||||
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Related Products Section */}
|
||||
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||
const productSlug = slug;
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: 'white',
|
||||
padding: '60px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Left Side: Content */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '55%' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#001a4d',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
marginBottom: '20px',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
KLZ Cables | {product.frontmatter.categories[0]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: '900',
|
||||
color: '#001a4d',
|
||||
lineHeight: '1.1',
|
||||
marginBottom: '30px',
|
||||
}}
|
||||
>
|
||||
{product.frontmatter.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
color: '#4a5568',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{product.frontmatter.description}
|
||||
</div>
|
||||
|
||||
{/* Accent Line */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '40px',
|
||||
width: '100px',
|
||||
height: '8px',
|
||||
backgroundColor: '#00ff99',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Product Image */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '40%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<img
|
||||
src={product.frontmatter.images[0].startsWith('http') ? product.frontmatter.images[0] : `https://klz-cables.com${product.frontmatter.images[0]}`}
|
||||
alt=""
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Subtle shadow under product */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10%',
|
||||
width: '60%',
|
||||
height: '20px',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(10px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Branding Corner */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '40px',
|
||||
right: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#001a4d' }}>KLZ</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'normal', color: '#00ff99', marginLeft: '4px' }}>Cables</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { getProductBySlug, getAllProducts } from '@/lib/mdx';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
locale: string;
|
||||
slug: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(productSlug)) {
|
||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t(`categories.${categoryKey}.title`);
|
||||
const categoryDesc = t(`categories.${categoryKey}.description`);
|
||||
|
||||
return {
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
openGraph: {
|
||||
title: categoryTitle,
|
||||
description: categoryDesc,
|
||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
if (!product) return {};
|
||||
|
||||
return {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
openGraph: {
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const components = {
|
||||
ProductTechnicalData,
|
||||
ProductTabs,
|
||||
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8" />,
|
||||
h2: (props: any) => (
|
||||
<div className="relative mt-20 mb-10">
|
||||
<h2 {...props} className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-primary tracking-tight" />
|
||||
<div className="absolute -bottom-4 left-0 w-24 h-1.5 bg-accent rounded-full" />
|
||||
</div>
|
||||
),
|
||||
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl lg:text-4xl font-bold text-primary mt-16 mb-6 tracking-tight" />,
|
||||
ul: (props: any) => <ul {...props} className="list-disc pl-8 mb-10 space-y-4 text-text-secondary text-lg md:text-xl" />,
|
||||
li: (props: any) => <li {...props} className="pl-2 marker:text-accent marker:font-bold" />,
|
||||
table: (props: any) => (
|
||||
<div className="overflow-x-auto my-16 rounded-3xl border border-neutral-dark/10 shadow-xl bg-white p-1">
|
||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||
</div>
|
||||
),
|
||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-xs font-black uppercase tracking-[0.25em] text-primary/60" />,
|
||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl" />,
|
||||
hr: () => <hr className="my-20 border-t-2 border-neutral-dark/5" />,
|
||||
blockquote: (props: any) => (
|
||||
<div className="my-12 p-8 md:p-12 bg-primary-dark rounded-3xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-48 h-48 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-medium" {...props} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(productSlug)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t(`categories.${categoryKey}.title`);
|
||||
|
||||
// Filter products for this category
|
||||
const filteredProducts = allProducts.filter(p =>
|
||||
p.frontmatter.categories.some(cat => cat.toLowerCase().replace(/\s+/g, '-') === productSlug)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
</nav>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-8 tracking-tight leading-[1.05]">
|
||||
{categoryTitle}
|
||||
</h1>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-neutral-light relative z-20 -mt-12 rounded-t-[48px] md:rounded-t-[64px]">
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredProducts.map((product) => (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/${locale}/products/${productSlug}/${product.slug}`}
|
||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||
>
|
||||
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
|
||||
{product.frontmatter.title}
|
||||
</h2>
|
||||
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
|
||||
if (!product) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryKey = categorySlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t(`categories.${categoryKey}.title`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* Product Hero */}
|
||||
<section className="relative pt-40 pb-32 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-5xl animate-slide-up">
|
||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-end justify-between gap-12">
|
||||
<div className="flex-1">
|
||||
{isFallback && (
|
||||
<div className="mb-6 inline-flex items-center px-3 py-1 rounded-md bg-accent/10 border border-accent/20 text-accent text-[10px] font-bold uppercase tracking-widest">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mr-2 animate-pulse" />
|
||||
{t('englishVersion')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
{product.frontmatter.categories.map((cat, idx) => (
|
||||
<Badge key={idx} variant="accent" className="bg-white/10 text-white/90 border-white/10 backdrop-blur-md px-4 py-1.5">
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-0 tracking-tight leading-[1.05]">
|
||||
{product.frontmatter.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section className="bg-neutral-light relative z-20 -mt-16 pt-0">
|
||||
<Container>
|
||||
{/* Large Product Image Section */}
|
||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||
<div className="relative -mt-24 mb-24 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="bg-white shadow-2xl border border-neutral-dark/5 overflow-hidden p-10 md:p-16 lg:p-20">
|
||||
<div className="relative w-full aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||
</div>
|
||||
|
||||
{product.frontmatter.images.length > 1 && (
|
||||
<div className="flex justify-center gap-6 mt-16">
|
||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/10 rounded-xl overflow-hidden bg-neutral-light/20 hover:border-accent transition-all duration-300 cursor-pointer group">
|
||||
<Image src={img} alt="" fill className="object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-16 lg:gap-24 relative items-start overflow-visible">
|
||||
<div className="flex-1 w-full">
|
||||
{/* Main Content Area */}
|
||||
<div className="prose prose-lg md:prose-xl prose-slate max-w-none prose-headings:text-primary prose-headings:font-extrabold prose-a:text-primary prose-strong:text-primary prose-img:rounded-3xl prose-img:shadow-2xl">
|
||||
<MDXRemote source={product.content} components={components} />
|
||||
</div>
|
||||
|
||||
{/* Structured Data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku,
|
||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Related Products */}
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-[450px] xl:w-[500px] lg:sticky lg:top-32">
|
||||
<div className="lg:translate-x-12 xl:translate-x-20">
|
||||
{/* Request Quote Form */}
|
||||
<div className="bg-white rounded-3xl shadow-2xl border border-neutral-dark/5 overflow-hidden">
|
||||
<div className="bg-primary-dark p-6 md:p-8 text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl" />
|
||||
|
||||
{/* Product Thumbnail for Sticky State */}
|
||||
{product.frontmatter.images?.[0] && (
|
||||
<div className="relative w-full aspect-[16/9] mb-6 rounded-xl overflow-hidden bg-white/10 backdrop-blur-md p-3 border border-white/10 z-10">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl md:text-2xl font-extrabold mb-2 relative z-10 tracking-tight">{t('requestQuote')}</h3>
|
||||
<p className="text-white/60 text-sm relative z-10 leading-relaxed">{t('requestQuoteDesc')}</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8">
|
||||
<RequestQuoteForm productName={product.frontmatter.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
app/[locale]/products/opengraph-image.tsx
Normal file
29
app/[locale]/products/opengraph-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t.has('meta.title')
|
||||
? t('meta.title')
|
||||
: t.has('breadcrumb')
|
||||
? t('breadcrumb')
|
||||
: 'Products';
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,123 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Card, Badge, Button } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t.has('meta.title')
|
||||
? t('meta.title')
|
||||
: t.has('breadcrumb')
|
||||
? t('breadcrumb')
|
||||
: 'Products';
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
|
||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductsPage({ params }: ProductsPageProps) {
|
||||
const t = useTranslations('Products');
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Get translated category slugs
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
const contactSlug = await mapFileSlugToTranslated('contact', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
{
|
||||
title: t('categories.lowVoltage.title'),
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${params.locale}/products/low-voltage-cables`
|
||||
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${params.locale}/products/medium-voltage-cables`
|
||||
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${params.locale}/products/high-voltage-cables`
|
||||
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${params.locale}/products/solar-cables`
|
||||
}
|
||||
href: `/${locale}/${productsSlug}/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-8 bg-white/10 text-white border border-white/20 backdrop-blur-md px-3 py-1 md:px-4 md:py-1.5">
|
||||
<Badge
|
||||
variant="saturated"
|
||||
className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5"
|
||||
>
|
||||
{t('heroSubtitle')}
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-7xl lg:text-8xl font-extrabold text-white mb-4 md:mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
|
||||
</span>
|
||||
)
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
})}
|
||||
</h1>
|
||||
<p className="text-lg md:text-2xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 md:gap-6">
|
||||
<Button href="#categories" variant="accent" size="lg" className="group w-full md:w-auto">
|
||||
<Button
|
||||
href="#categories"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto"
|
||||
>
|
||||
{t('viewProducts')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||
</Button>
|
||||
@@ -77,30 +126,46 @@ export default function ProductsPage({ params }: ProductsPageProps) {
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<Section id="categories" className="bg-neutral-light relative z-20 -mt-8 md:-mt-16 rounded-t-[32px] md:rounded-t-[64px] pt-12 md:pt-24">
|
||||
<Section id="categories" className="bg-neutral-light relative">
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
||||
{categories.map((category, idx) => (
|
||||
<Reveal key={idx} delay={idx * 100}>
|
||||
<Link key={idx} href={category.href} className="group block">
|
||||
<TrackedLink
|
||||
key={idx}
|
||||
href={category.href}
|
||||
className="group block"
|
||||
eventProperties={{
|
||||
category_title: category.title,
|
||||
location: 'products_index',
|
||||
}}
|
||||
>
|
||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||
<Image
|
||||
src={category.img}
|
||||
<Image
|
||||
src={category.img}
|
||||
alt={category.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||
|
||||
|
||||
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
||||
<img src={category.icon} alt="" className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
|
||||
<Image
|
||||
src={category.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
||||
<Badge variant="accent" className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs">
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs"
|
||||
>
|
||||
{t('categoryLabel')}
|
||||
</Badge>
|
||||
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
||||
@@ -112,45 +177,66 @@ export default function ProductsPage({ params }: ProductsPageProps) {
|
||||
<p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8 line-clamp-2 md:line-clamp-none">
|
||||
{category.desc}
|
||||
</p>
|
||||
<div className="flex items-center text-primary font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
|
||||
<span className="border-b-2 border-saturated/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('viewProducts')}
|
||||
</span>
|
||||
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
||||
<svg
|
||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</TrackedLink>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Technical Support CTA */}
|
||||
<Section className="bg-white py-12 md:py-28">
|
||||
<Container>
|
||||
<div className="bg-primary-dark rounded-[32px] md:rounded-[64px] p-6 md:p-20 lg:p-24 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
{t('cta.description')}
|
||||
</p>
|
||||
<Reveal>
|
||||
<Section className="bg-white py-12 md:py-28">
|
||||
<Container>
|
||||
<div className="bg-primary-dark rounded-[32px] md:rounded-[64px] p-6 md:p-20 lg:p-24 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
{t('cta.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
href={`/${locale}/${contactSlug}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 px-6 md:px-10 text-sm md:text-xl"
|
||||
>
|
||||
{t('cta.button')}
|
||||
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
||||
{t('cta.button')}
|
||||
<span className="ml-4 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
</Container>
|
||||
</Section>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
25
app/[locale]/team/opengraph-image.tsx
Normal file
25
app/[locale]/team/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,163 +1,272 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
import TrackedButton from '@/components/analytics/TrackedButton';
|
||||
|
||||
export default function TeamPage() {
|
||||
const t = useTranslations('Team');
|
||||
interface TeamPageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/team`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/team`,
|
||||
en: `${SITE_URL}/en/team`,
|
||||
'x-default': `${SITE_URL}/en/team`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `${SITE_URL}/${locale}/team`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TeamPage({ params }: TeamPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
id="breadcrumb-team"
|
||||
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||
/>
|
||||
<JsonLd
|
||||
id="person-michael"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: t('michael.name'),
|
||||
jobTitle: t('michael.role'),
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
<JsonLd
|
||||
id="person-klaus"
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: t('klaus.name'),
|
||||
jobTitle: t('klaus.role'),
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07655-Large.webp"
|
||||
alt="KLZ Team"
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||
</div>
|
||||
|
||||
<Container className="relative z-10 text-center text-white max-w-5xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
|
||||
<h1 className="text-3xl md:text-7xl lg:text-8xl font-extrabold tracking-tight leading-[1.1] mb-4 md:mb-8">
|
||||
{t('hero.subtitle')}
|
||||
</h1>
|
||||
<p className="text-lg md:text-3xl text-white/70 font-medium italic">
|
||||
{t('hero.title')}
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
<Reveal>
|
||||
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07655-Large.webp"
|
||||
alt="KLZ Team"
|
||||
fill
|
||||
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||
</div>
|
||||
|
||||
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||
{t('hero.badge')}
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t('hero.subtitle')}
|
||||
</Heading>
|
||||
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||
{t('hero.title')}
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<article className="relative bg-white overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="relative z-10">
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">{t('michael.role')}</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
<span className="text-white">{t('michael.name')}</span>
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
"{t('michael.quote')}"
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||
{t('michael.quote')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||
{t('michael.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
<TrackedButton
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||
eventProperties={{
|
||||
type: 'social_linkedin',
|
||||
person: 'Michael Bodemer',
|
||||
location: 'team_page',
|
||||
}}
|
||||
>
|
||||
{t('michael.linkedin')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07768-Large.webp"
|
||||
alt={t('michael.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Legacy Section - Immersive Background */}
|
||||
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy.webp"
|
||||
alt={t('legacy.subtitle')}
|
||||
fill
|
||||
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||
<div className="lg:col-span-6">
|
||||
<Heading level={2} subtitle={t('legacy.subtitle')} className="text-white mb-6 md:mb-10">
|
||||
<span className="text-white">{t('legacy.title')}</span>
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||
{t('legacy.p1')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
|
||||
{t('legacy.p2')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.network')}</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Reveal>
|
||||
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy.webp"
|
||||
alt={t('legacy.subtitle')}
|
||||
fill
|
||||
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
<Container className="relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||
<div className="lg:col-span-6">
|
||||
<Heading
|
||||
level={2}
|
||||
subtitle={t('legacy.subtitle')}
|
||||
className="text-white mb-6 md:mb-10"
|
||||
>
|
||||
<span className="text-white">{t('legacy.title')}</span>
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||
{t('legacy.p1')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{t('legacy.expertise')}
|
||||
</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||
{t('legacy.expertiseDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{t('legacy.network')}
|
||||
</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||
{t('legacy.networkDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
{/* Klaus Mintel Section - Reversed Split Layout */}
|
||||
<section className="relative bg-white overflow-hidden">
|
||||
<article className="relative bg-white overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC07963-Large.webp"
|
||||
alt={t('klaus.name')}
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||
quality={100}
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||
</div>
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-primary relative order-2">
|
||||
<div className="absolute top-0 left-0 w-32 h-full bg-primary/5 skew-x-12 -translate-x-1/2" />
|
||||
</Reveal>
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
||||
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
||||
<div className="relative z-10">
|
||||
<Badge variant="primary" className="mb-4 md:mb-8">{t('klaus.role')}</Badge>
|
||||
<Heading level={2} className="text-primary mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
<div className="relative mb-6 md:mb-12">
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-primary rounded-full" />
|
||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
"{t('klaus.quote')}"
|
||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||
{t('klaus.quote')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||
{t('klaus.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="primary"
|
||||
<TrackedButton
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||
eventProperties={{
|
||||
type: 'social_linkedin',
|
||||
person: 'Klaus Mintel',
|
||||
location: 'team_page',
|
||||
}}
|
||||
>
|
||||
{t('klaus.linkedin')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
</Button>
|
||||
</TrackedButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* Manifesto Section - Modern Grid */}
|
||||
<Section className="bg-white text-primary py-16 md:py-28">
|
||||
@@ -171,55 +280,47 @@ export default function TeamPage() {
|
||||
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||
{t('manifesto.tagline')}
|
||||
</p>
|
||||
|
||||
|
||||
{/* Mobile-only progress indicator */}
|
||||
<div className="flex lg:hidden mt-8 gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden">
|
||||
<div
|
||||
key={i}
|
||||
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
|
||||
>
|
||||
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||
<div key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none">
|
||||
<li
|
||||
key={idx}
|
||||
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||
>
|
||||
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
|
||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
||||
0{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||
{t(`manifesto.items.${idx}.title`)}
|
||||
</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||
{t(`manifesto.items.${idx}.description`)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
{/* Gallery Section - Premium Treatment */}
|
||||
<Section className="bg-primary-dark py-16 md:py-32">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
||||
<span className="text-white">{t('gallery.title')}</span>
|
||||
</Heading>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 md:gap-6">
|
||||
{[
|
||||
'/uploads/2024/12/DSC07539-Large-600x400.webp',
|
||||
'/uploads/2024/12/DSC07460-Large-600x400.webp',
|
||||
'/uploads/2024/12/DSC07469-Large-600x400.webp',
|
||||
'/uploads/2024/12/DSC07433-Large-600x400.webp',
|
||||
'/uploads/2024/12/DSC07387-Large-600x400.webp'
|
||||
].map((src, idx) => (
|
||||
<div key={idx} className="relative aspect-[3/4] rounded-2xl md:rounded-[32px] overflow-hidden group shadow-2xl">
|
||||
<Image src={src} alt={t('gallery.title')} fill className="object-cover transition-transform duration-1000 group-hover:scale-110" />
|
||||
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-transparent transition-all duration-500" />
|
||||
<div className="absolute inset-0 border-0 group-hover:border-[8px] md:group-hover:border-[12px] border-white/10 transition-all duration-500 pointer-events-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
<Reveal>
|
||||
<Gallery />
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
123
app/actions/brochure.ts
Normal file
123
app/actions/brochure.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
message: `Brochure download request (${locale})`,
|
||||
type: 'brochure_download' as any,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved brochure request to Payload CMS', { email });
|
||||
} catch (error) {
|
||||
logger.error('Failed to store brochure request in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
|
||||
}
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
_email: email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
188
app/actions/contact.ts
Normal file
188
app/actions/contact.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS
|
||||
try {
|
||||
const { getPayload } = await import('payload');
|
||||
const configPromise = (await import('@payload-config')).default;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.create({
|
||||
collection: 'form-submissions',
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved form submission to Payload CMS', {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Payload CMS', { error });
|
||||
services.errors.captureException(error, { action: 'payload_store_submission' });
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
const isTestSubmission = email === 'testing@mintel.me';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Notification email FAILED', {
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
email,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{ action: 'sendContactFormAction_notification', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping notification email for test submission', { email });
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
clientName: 'KLZ Cables',
|
||||
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Confirmation email FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||
{ action: 'sendContactFormAction_confirmation', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping confirmation email for test submission', { email });
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
await services.notifications.notify({
|
||||
title: `📩 ${notificationSubject}`,
|
||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
// Track success
|
||||
services.analytics.track('contact-form-success', {
|
||||
is_product_request: !!productName,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to send branded emails', {
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||
|
||||
await services.notifications.notify({
|
||||
title: '🚨 Contact Form Error',
|
||||
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||
priority: 8,
|
||||
});
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
218
app/api/ai-search/route.ts
Normal file
218
app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
||||
import { searchProducts } from '../../../src/lib/qdrant';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import { z } from 'zod';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
// @ts-expect-error - Local version of @mintel/payload-ai/tools/mcpAdapter might not have types published yet
|
||||
import { createMcpTools } from '@mintel/payload-ai/tools/mcpAdapter';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60; // Max allowed duration (Vercel)
|
||||
|
||||
// Config and constants
|
||||
const RATE_LIMIT_POINTS = 20; // 20 requests per minute
|
||||
const RATE_LIMIT_DURATION = 60; // 1 minute window
|
||||
const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day
|
||||
const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h
|
||||
const MAX_CONVERSATION_MESSAGES = 20; // max messages in context
|
||||
const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like
|
||||
|
||||
// Removed requestSchema as it's replaced by direct parsing
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Changed req type to NextRequest
|
||||
try {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
const { messages, honeypot } = body;
|
||||
|
||||
// Get client IP for rate limiting
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown';
|
||||
|
||||
// 1. Basic Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
const isBot = honeypot && honeypot.length > 0;
|
||||
|
||||
// Check if the input itself is obviously spam/too long
|
||||
if (latestMessage.length > 500) {
|
||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Honeypot check
|
||||
if (isBot) {
|
||||
console.warn('Honeypot triggered in AI search');
|
||||
// Tarpit the bot
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||
products: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Rate Limiting via Redis (IP-based)
|
||||
try {
|
||||
// Per-minute burst limit
|
||||
const minuteKey = `ai_rate:${clientIp}:min`;
|
||||
const minuteCount = await redis.incr(minuteKey);
|
||||
if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION);
|
||||
|
||||
if (minuteCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Zu viele Anfragen. Bitte warte einen Moment.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Daily budget limit
|
||||
const dayKey = `ai_rate:${clientIp}:day`;
|
||||
const dayCount = await redis.incr(dayKey);
|
||||
if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION);
|
||||
|
||||
if (dayCount > DAILY_BUDGET_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||
// Fail open if Redis is down
|
||||
}
|
||||
|
||||
// 4. Cap conversation length to limit token usage
|
||||
const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
// 4. Fetch Context from Qdrant based on the latest message
|
||||
let contextStr = '';
|
||||
let foundProducts: any[] = [];
|
||||
|
||||
// Team context — hardcoded from translation data (no Payload collection for team)
|
||||
const teamContextStr = `
|
||||
Das ECHTE KLZ Team:
|
||||
- Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig
|
||||
- Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`;
|
||||
|
||||
try {
|
||||
const searchResults = await searchProducts(latestMessage, 5);
|
||||
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
const productDescriptions = searchResults
|
||||
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
if (productDescriptions) {
|
||||
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
|
||||
}
|
||||
|
||||
foundProducts = searchResults
|
||||
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
||||
.map((p: any) => ({
|
||||
id: p.id as string,
|
||||
title: p.payload?.data?.title as string,
|
||||
sku: p.payload?.data?.sku as string,
|
||||
slug: p.payload?.data?.slug as string,
|
||||
}));
|
||||
}
|
||||
} catch (searchError) {
|
||||
console.error('Qdrant Search Error:', searchError);
|
||||
Sentry.captureException(searchError, { tags: { context: 'ai-search-qdrant' } });
|
||||
// We can still proceed without context if Qdrant fails
|
||||
}
|
||||
|
||||
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
||||
const systemPrompt = `Du bist "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands.
|
||||
|
||||
STIL & PERSÖNLICHKEIT:
|
||||
- Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze).
|
||||
- Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top).
|
||||
- Kein Markdown, nur Fließtext.
|
||||
- NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden.
|
||||
|
||||
DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
|
||||
- Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y.").
|
||||
- Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht.
|
||||
- FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN!
|
||||
- FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will.
|
||||
- Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst."
|
||||
- Wenn technisches Wissen aus dem Kabelhandbuch benötigt wird, NUTZE UNBEDINGT eines der "kabelfachmann_*" Tools, anstatt zu raten oder zu behaupten du wüsstest es nicht! Das Tool weiss alles.
|
||||
|
||||
VORGEHEN:
|
||||
1. Prüfe den KONTEXT auf passende Katalog-Kabel für das Kundenprojekt.
|
||||
2. Wenn du tiefgehendes Wissen zu einem Kabeltyp brauchst (z.B. Biegeradius, Normen, Querschnitte), rufe das Kabelfachmann-Tool auf.
|
||||
3. Nenne direkt 1-2 passende Produktserien aus dem Kontext oder der Tool-Abfrage, die für diesen Fall Sinn machen.
|
||||
4. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
|
||||
5. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
|
||||
|
||||
GRENZEN:
|
||||
- PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab.
|
||||
- Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote.
|
||||
|
||||
KONTEXT KABEL & TEAM:
|
||||
${contextStr || 'Kein Katalogkontext verfügbar.'}
|
||||
${teamContextStr}
|
||||
`;
|
||||
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openrouterApiKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: openrouterApiKey,
|
||||
});
|
||||
|
||||
let mcpTools: Record<string, any> = {};
|
||||
const mcpUrl = process.env.KABELFACHMANN_MCP_URL || 'http://host.docker.internal:3007/sse';
|
||||
try {
|
||||
const { tools } = await createMcpTools({
|
||||
name: 'kabelfachmann',
|
||||
url: mcpUrl,
|
||||
});
|
||||
mcpTools = tools;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load MCP tools', e);
|
||||
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
|
||||
}
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openrouter('openai/gpt-4o-mini'),
|
||||
system: systemPrompt,
|
||||
messages: cappedMessages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
})),
|
||||
tools: mcpTools,
|
||||
// @ts-expect-error - maxSteps might be missing in some versions of generateText types
|
||||
maxSteps: 5,
|
||||
temperature: 0.3,
|
||||
maxTokens: MAX_RESPONSE_TOKENS,
|
||||
});
|
||||
|
||||
// Return the AI's answer along with any found products
|
||||
return NextResponse.json({
|
||||
answerText: text,
|
||||
products: foundProducts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('AI Search API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||
return NextResponse.json(
|
||||
{ error: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
49
app/api/health/cms/route.ts
Normal file
49
app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Deep CMS Health Check
|
||||
* Validates that Payload CMS can actually query the database.
|
||||
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||
*/
|
||||
export async function GET() {
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
checks.init = 'ok';
|
||||
|
||||
// Ensure migrations are applied on startup (reliable for standalone builds)
|
||||
try {
|
||||
await payload.db.migrate();
|
||||
} catch (e: any) {
|
||||
console.error('Migration failed:', e.message);
|
||||
// We continue to check the collections even if migration fails
|
||||
}
|
||||
|
||||
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
await payload.find({ collection, limit: 1, locale: 'en' });
|
||||
checks[collection] = 'ok';
|
||||
} catch (e: any) {
|
||||
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
||||
return NextResponse.json(
|
||||
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||
{ status: hasErrors ? 503 : 200 },
|
||||
);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: e.message?.substring(0, 200), checks },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
32
app/api/save-session/route.ts
Normal file
32
app/api/save-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Only allow in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
// Ensure we are in the project root by using process.cwd()
|
||||
// Path: <project-root>/remotion/session.json
|
||||
const remotionDir = path.join(process.cwd(), 'remotion');
|
||||
const filePath = path.join(remotionDir, 'session.json');
|
||||
|
||||
// Create remotion directory if it doesn't exist
|
||||
if (!fs.existsSync(remotionDir)) {
|
||||
fs.mkdirSync(remotionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the JSON file
|
||||
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
|
||||
|
||||
return NextResponse.json({ success: true, path: filePath });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save session:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
165
app/api/sync-qdrant/route.ts
Normal file
165
app/api/sync-qdrant/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../../../payload.config';
|
||||
import { upsertProductVector } from '../../../src/lib/qdrant';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
/**
|
||||
* Internal endpoint called by the warmup script on every dev boot.
|
||||
* Syncs posts, pages, and products from Payload CMS into Qdrant.
|
||||
* NOT for form entries, media, or users.
|
||||
*/
|
||||
export async function GET() {
|
||||
const results = { products: 0, posts: 0, pages: 0, errors: [] as string[] };
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// ── Products ──
|
||||
const { docs: products } = await payload.find({
|
||||
collection: 'products',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const product of products) {
|
||||
try {
|
||||
const contentText = `${product.title} - SKU: ${product.sku}\n${product.description || ''}`;
|
||||
await upsertProductVector(String(product.id), contentText, {
|
||||
type: 'product',
|
||||
data: {
|
||||
title: product.title,
|
||||
sku: product.sku,
|
||||
slug: product.slug,
|
||||
description: product.description,
|
||||
},
|
||||
});
|
||||
results.products++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`product:${product.sku}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Posts ──
|
||||
const { docs: posts } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
try {
|
||||
const contentText = [
|
||||
`Blog-Artikel: ${post.title}`,
|
||||
post.excerpt ? `Zusammenfassung: ${post.excerpt}` : '',
|
||||
post.category ? `Kategorie: ${post.category}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
await upsertProductVector(`post_${post.id}`, contentText, {
|
||||
type: 'knowledge',
|
||||
content: contentText,
|
||||
data: {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
},
|
||||
});
|
||||
results.posts++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`post:${post.slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pages ──
|
||||
const { docs: pages } = await payload.find({
|
||||
collection: 'pages',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const contentText = [
|
||||
`Seite: ${page.title}`,
|
||||
page.excerpt ? `Beschreibung: ${page.excerpt}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
await upsertProductVector(`page_${page.id}`, contentText, {
|
||||
type: 'knowledge',
|
||||
content: contentText,
|
||||
data: {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
},
|
||||
});
|
||||
results.pages++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`page:${page.slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kabelhandbuch (Static Text) ──
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = await import('crypto');
|
||||
|
||||
const txtPath = path.join(process.cwd(), 'kabelhandbuch.txt');
|
||||
let manualChunks = 0;
|
||||
|
||||
if (fs.existsSync(txtPath)) {
|
||||
try {
|
||||
const text = fs.readFileSync(txtPath, 'utf8');
|
||||
const chunks = text
|
||||
.split(/\n\s*\n/)
|
||||
.map((c: string) => c.trim())
|
||||
.filter((c: string) => c.length > 50);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkText = chunks[i];
|
||||
const syntheticId = crypto.randomUUID();
|
||||
|
||||
await upsertProductVector(syntheticId, chunkText, {
|
||||
type: 'knowledge',
|
||||
content: chunkText,
|
||||
data: {
|
||||
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
||||
source: 'Kabelhandbuch KLZ.pdf',
|
||||
},
|
||||
});
|
||||
manualChunks++;
|
||||
}
|
||||
console.log(`[Qdrant Sync] ✅ ${manualChunks} Kabelhandbuch-Chunks synced`);
|
||||
} catch (e: any) {
|
||||
results.errors.push(`kabelhandbuch: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Qdrant Sync] ⚠️ skipped Kabelhandbuch: ${txtPath} not found`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced, ${manualChunks} manual chunks synced`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: {
|
||||
products: results.products,
|
||||
posts: results.posts,
|
||||
pages: results.pages,
|
||||
},
|
||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Qdrant Sync] ❌ Fatal error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||
}
|
||||
75
app/errors/api/relay/route.ts
Normal file
75
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||
*
|
||||
* This Route Handler receives Sentry envelopes from the client,
|
||||
* injects the correct DSN if needed, and forwards them to the
|
||||
* internal GlitchTip/Sentry instance.
|
||||
*
|
||||
* This hides the real DSN from the client and bypasses ad-blockers
|
||||
* that target Sentry's default ingest endpoints.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||
|
||||
try {
|
||||
// Prevent 403 Forbidden console noise in local dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
const envelope = await request.text();
|
||||
|
||||
// Sentry envelopes can contain multiple parts separated by newlines
|
||||
const lines = envelope.split('\n');
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||
}
|
||||
|
||||
JSON.parse(lines[0]);
|
||||
const realDsn = config.errors.glitchtip.dsn;
|
||||
|
||||
if (!realDsn) {
|
||||
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(realDsn);
|
||||
const projectId = dsnUrl.pathname.replace('/', '');
|
||||
const sentryKey = dsnUrl.username;
|
||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
|
||||
|
||||
logger.debug('Relaying Sentry envelope', {
|
||||
projectId,
|
||||
host: dsnUrl.host,
|
||||
});
|
||||
|
||||
const response = await fetch(relayUrl, {
|
||||
method: 'POST',
|
||||
body: envelope,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Sentry/GlitchTip API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to relay Sentry request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
24
app/global-error.tsx
Normal file
24
app/global-error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import Error from 'next/error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* This is the default Next.js error component but it doesn't allow omitting the statusCode */}
|
||||
<Error statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const services = getServerAppServices();
|
||||
services.logger.debug('Health check requested');
|
||||
return new Response('OK', { status: 200 });
|
||||
}
|
||||
|
||||
30
app/manifest.ts
Normal file
30
app/manifest.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'KLZ Cables',
|
||||
short_name: 'KLZ',
|
||||
description: 'Premium Cable Solutions',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#001a4d',
|
||||
theme_color: '#001a4d',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
{
|
||||
src: '/logo.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/apple-touch-icon.png',
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/health/'],
|
||||
},
|
||||
{
|
||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||
allow: '/',
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
122
app/sitemap.ts
Normal file
122
app/sitemap.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getAllProductsMetadata } from '@/lib/products';
|
||||
import { getAllPostsMetadata } from '@/lib/blog';
|
||||
import { getAllPagesMetadata } from '@/lib/pages';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
// Helper to generate localized URL Segment
|
||||
const getLocalizedRoute = async (pageKey: string) => {
|
||||
if (pageKey === '') return '';
|
||||
const translated = await mapFileSlugToTranslated(pageKey, locale);
|
||||
return `/${translated}`;
|
||||
};
|
||||
|
||||
// Static routes
|
||||
const staticPages = ['', 'blog', 'contact', 'team', 'products'];
|
||||
for (const page of staticPages) {
|
||||
const localizedRoute = await getLocalizedRoute(page);
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}${localizedRoute}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: page === '' ? 'daily' : 'weekly',
|
||||
priority: page === '' ? 1 : 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// Categories routes
|
||||
const productCategories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
|
||||
const translatedProducts = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
for (const category of productCategories) {
|
||||
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// Products
|
||||
const productsMetadata = await getAllProductsMetadata(locale);
|
||||
for (const product of productsMetadata) {
|
||||
if (!product.frontmatter || !product.slug) continue;
|
||||
|
||||
const firstCat = product.frontmatter.categories[0] || '';
|
||||
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||
let categoryFileSlug = 'low-voltage-cables';
|
||||
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||
categoryFileSlug = 'high-voltage-cables';
|
||||
else if (
|
||||
normalizedCat === 'mittelspannungskabel' ||
|
||||
normalizedCat === 'medium-voltage-cables'
|
||||
)
|
||||
categoryFileSlug = 'medium-voltage-cables';
|
||||
else if (
|
||||
normalizedCat === 'solarkabel' ||
|
||||
normalizedCat === 'solar-cables' ||
|
||||
normalizedCat === 'solar'
|
||||
)
|
||||
categoryFileSlug = 'solar-cables';
|
||||
|
||||
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
|
||||
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
|
||||
const postsMetadata = await getAllPostsMetadata(locale);
|
||||
for (const post of postsMetadata) {
|
||||
if (!post.frontmatter || !post.slug) continue;
|
||||
|
||||
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`,
|
||||
lastModified: new Date(post.frontmatter.date),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
});
|
||||
}
|
||||
|
||||
// Static pages
|
||||
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||
for (const page of pagesMetadata) {
|
||||
if (!page.slug) continue;
|
||||
|
||||
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${translatedSlug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sitemapEntries;
|
||||
}
|
||||
101
app/stats/api/send/route.ts
Normal file
101
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy for Umami Analytics.
|
||||
*
|
||||
* This Route Handler receives tracking events from the browser,
|
||||
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||
* internal Umami API endpoint.
|
||||
*
|
||||
* This ensures:
|
||||
* 1. The Website ID is NOT leaked to the client bundle.
|
||||
* 2. The Umami API endpoint is hidden behind our domain.
|
||||
* 3. We have full control over the tracking data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||
|
||||
try {
|
||||
// Prevent 400 Bad Request console noise in local dev
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
// Inject the secret websiteId from server config
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
if (!websiteId) {
|
||||
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: websiteId,
|
||||
};
|
||||
|
||||
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||
|
||||
// Log the event (internal only)
|
||||
logger.debug('Forwarding analytics event', {
|
||||
type,
|
||||
url: payload.url,
|
||||
website: websiteId.slice(0, 8) + '...',
|
||||
});
|
||||
|
||||
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (!process.env.CI) {
|
||||
logger.error('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Console error to ensure it appears in logs even if logger fails
|
||||
if (!process.env.CI) {
|
||||
console.error('CRITICAL PROXY ERROR:', {
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
endpoint: config.analytics.umami.apiEndpoint,
|
||||
});
|
||||
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
details: errorMessage, // Expose error for debugging
|
||||
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
39
check-data.ts
Normal file
39
check-data.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
async function checkData() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
|
||||
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
|
||||
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
|
||||
|
||||
const checkDocs = (name: string, docs: any[]) => {
|
||||
console.log(`\n----- ${name.toUpperCase()} -----`);
|
||||
docs.forEach((p) => {
|
||||
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
|
||||
if (Array.isArray(p.content)) {
|
||||
console.log(
|
||||
'Content is ARRAY (Slate format!)',
|
||||
JSON.stringify(p.content).substring(0, 100),
|
||||
);
|
||||
} else if (p.content && p.content.root) {
|
||||
console.log('Content is Lexical format.');
|
||||
} else {
|
||||
console.log('Content is UNKNOWN format.');
|
||||
console.log(JSON.stringify(p.content).substring(0, 100));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkDocs('posts', posts);
|
||||
checkDocs('products', products);
|
||||
checkDocs('pages', pages);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
checkData();
|
||||
9
commitlint.config.cjs
Normal file
9
commitlint.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'header-max-length': [2, 'always', 500],
|
||||
'subject-case': [0],
|
||||
'subject-full-stop': [0],
|
||||
},
|
||||
};
|
||||
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
88
components/BrochureCTA.tsx
Normal file
88
components/BrochureCTA.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||
* The full-catalog PDF is ONLY revealed after email submission.
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
256
components/BrochureModal.tsx
Normal file
256
components/BrochureModal.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Close on escape + lock scroll + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Auto-focus input when opened
|
||||
const firstInput = document.getElementById('brochure-email');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 50);
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
|
||||
if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
if (focusable.length > 0) {
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Strict overflow lock on mobile as well
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
setState('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
|
||||
>
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
86
components/CMSConnectivityNotice.tsx
Normal file
86
components/CMSConnectivityNotice.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { config } from '../lib/config';
|
||||
|
||||
export default function CMSConnectivityNotice() {
|
||||
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only show if we've detected an issue AND we are in a context where we want to see it
|
||||
const checkCMS = async () => {
|
||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||
const isLocal = config.isDevelopment;
|
||||
const isTesting = config.isTesting;
|
||||
const target = process.env.NEXT_PUBLIC_TARGET || '';
|
||||
const isBranch = target === 'branch';
|
||||
|
||||
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
|
||||
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/health/cms');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
setStatus('error');
|
||||
setErrorMsg(data.message);
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setStatus('ok');
|
||||
setIsVisible(false);
|
||||
}
|
||||
} catch {
|
||||
// If it's a connection error, only show if we are really debugging
|
||||
if (isDebug || isLocal) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Could not connect to CMS health endpoint');
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkCMS();
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
|
||||
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-white/20 p-2 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||
{errorMsg === 'relation "products" does not exist'
|
||||
? 'The database schema is missing. Please run migrations for this environment.'
|
||||
: 'A content service is unavailable. Check the deployment logs for details.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
components/ContactForm.tsx
Normal file
233
components/ContactForm.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
export default function ContactForm() {
|
||||
const t = useTranslations('Contact');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
const handleFocus = (fieldId: string) => {
|
||||
// Initial form start
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
trackEvent(AnalyticsEvents.FORM_START, {
|
||||
form_id: 'contact_form',
|
||||
form_name: 'Contact',
|
||||
});
|
||||
}
|
||||
|
||||
// Field-level transparency
|
||||
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||
form_id: 'contact_form',
|
||||
field_id: fieldId,
|
||||
});
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus('submitting');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result?.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'general',
|
||||
email,
|
||||
});
|
||||
setStatus('success');
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} else {
|
||||
console.error('Contact form submission failed:', { email, error: result.error });
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'contact_form',
|
||||
error: result.error || 'submission_failed',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', { email, error });
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'contact_form',
|
||||
error: (error as Error).message || 'unexpected_error',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4">
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<p className="text-text-secondary text-lg mb-8">
|
||||
{t('form.successDesc') ||
|
||||
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||
{t('form.sendAnother') || 'Send another message'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Card
|
||||
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-10 h-10 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4 text-destructive font-black">
|
||||
{t('form.errorTitle') || 'Submission Failed!'}
|
||||
</Heading>
|
||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{t('form.tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
{t('form.title')}
|
||||
</Heading>
|
||||
<form
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
onFocus={() => handleFocus('contact-name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="contact-email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="contact-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('contact-email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="contact-message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('contact-message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('form.submitting') || 'Sending...'}
|
||||
</span>
|
||||
) : (
|
||||
t('form.submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ContactMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||
}
|
||||
92
components/DatasheetDownload.tsx
Normal file
92
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface DatasheetDownloadProps {
|
||||
datasheetPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={datasheetPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: datasheetPath.split('/').pop(),
|
||||
file_path: datasheetPath,
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadDatasheetDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
components/FeedbackClientWrapper.tsx
Normal file
18
components/FeedbackClientWrapper.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const FeedbackOverlay = dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface FeedbackClientWrapperProps {
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
|
||||
if (!feedbackEnabled) return null;
|
||||
|
||||
return <FeedbackOverlay />;
|
||||
}
|
||||
@@ -1,79 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { Container, Heading } from './ui';
|
||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import FooterBrochureForm from './FooterBrochureForm';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
const navT = useTranslations('Navigation');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-primary-dark text-white py-24 relative overflow-hidden">
|
||||
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
|
||||
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Link href={`/${locale}`} className="inline-block group">
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt={t('products')}
|
||||
width={150}
|
||||
height={40}
|
||||
<h2 className="sr-only">Footer Navigation</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||
{/* Brand Column – full width on mobile */}
|
||||
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-block group"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/logo-white.svg"
|
||||
alt="KLZ Vertriebs GmbH"
|
||||
width={150}
|
||||
height={40}
|
||||
style={{ width: 'auto' }}
|
||||
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-white/60 text-lg leading-relaxed max-w-sm">
|
||||
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
|
||||
{t('tagline')}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
|
||||
<a
|
||||
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
type: 'social',
|
||||
target: 'linkedin',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
|
||||
>
|
||||
<span className="sr-only">LinkedIn</span>
|
||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('legal')}</h4>
|
||||
<ul className="space-y-4 text-white/70">
|
||||
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="hover:text-white transition-colors">{t('legalNotice')}</Link></li>
|
||||
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="hover:text-white transition-colors">{t('privacyPolicy')}</Link></li>
|
||||
<li><Link href={`/${locale}/${t('termsSlug')}`} className="hover:text-white transition-colors">{t('terms')}</Link></li>
|
||||
{/* Legal Column */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('legal')}
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/${t('legalNoticeSlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('legalNotice'),
|
||||
href: t('legalNoticeSlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('legalNotice')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/${t('privacyPolicySlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('privacyPolicy'),
|
||||
href: t('privacyPolicySlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('privacyPolicy')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/${t('termsSlug')}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: t('terms'),
|
||||
href: t('termsSlug'),
|
||||
location: 'footer_legal',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('terms')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('company')}</h4>
|
||||
<ul className="space-y-4 text-white/70">
|
||||
<li><Link href={`/${locale}/team`} className="hover:text-white transition-colors">{navT('team')}</Link></li>
|
||||
<li><Link href={`/${locale}/products`} className="hover:text-white transition-colors">{navT('products')}</Link></li>
|
||||
<li><Link href={`/${locale}/blog`} className="hover:text-white transition-colors">{navT('blog')}</Link></li>
|
||||
<li><Link href={`/${locale}/contact`} className="hover:text-white transition-colors">{navT('contact')}</Link></li>
|
||||
{/* Company Column */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('company')}
|
||||
</h3>
|
||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/team`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('team'),
|
||||
href: '/team',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
>
|
||||
{navT('team')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('products'),
|
||||
href: locale === 'de' ? '/produkte' : '/products',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
>
|
||||
{navT('products')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('blog'),
|
||||
href: '/blog',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
>
|
||||
{navT('blog')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: navT('contact'),
|
||||
href: locale === 'de' ? '/kontakt' : '/contact',
|
||||
location: 'footer_company',
|
||||
})
|
||||
}
|
||||
>
|
||||
{navT('contact')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts Column */}
|
||||
<div className="lg:col-span-4">
|
||||
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('recentPosts')}</h4>
|
||||
<ul className="space-y-6">
|
||||
{/* Recent Posts Column – full width on mobile */}
|
||||
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||
{t('recentPosts')}
|
||||
</h3>
|
||||
<ul className="space-y-6 list-none m-0 p-0">
|
||||
{[
|
||||
"Focus on wind farm construction: three typical cable challenges",
|
||||
"Why the N2XS(F)2Y is the ideal cable for your energy project"
|
||||
{
|
||||
title:
|
||||
locale === 'de'
|
||||
? 'Windparkbau im Fokus: drei typische Kabelherausforderungen'
|
||||
: 'Focus on wind farm construction: three typical cable challenges',
|
||||
slug:
|
||||
locale === 'de'
|
||||
? 'windparkbau-im-fokus-drei-typische-kabelherausforderungen'
|
||||
: 'focus-on-wind-farm-construction-three-typical-cable-challenges',
|
||||
},
|
||||
{
|
||||
title:
|
||||
locale === 'de'
|
||||
? 'Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist'
|
||||
: 'Why the N2XS(F)2Y is the ideal cable for your energy project',
|
||||
slug:
|
||||
locale === 'de'
|
||||
? 'n2xsf2y-mittelspannungskabel-energieprojekt'
|
||||
: 'why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
|
||||
},
|
||||
].map((post, i) => (
|
||||
<li key={i}>
|
||||
<Link href="#" className="group block">
|
||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2">
|
||||
{post}
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.slug}`}
|
||||
className="group block text-white/80"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
location: 'footer_recent',
|
||||
})
|
||||
}
|
||||
>
|
||||
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
|
||||
{post.title}
|
||||
</p>
|
||||
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} →</span>
|
||||
<span className="text-xs text-white/70 uppercase tracking-widest">
|
||||
{t('readArticle')} →
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -81,11 +245,83 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-sm font-medium">
|
||||
<div className="mb-12 md:mb-16">
|
||||
<FooterBrochureForm />
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
<Link href="/en" className="hover:text-white transition-colors">English</Link>
|
||||
<Link href="/de" className="hover:text-white transition-colors">Deutsch</Link>
|
||||
<Link
|
||||
href="/en"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: locale,
|
||||
to: 'en',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
English
|
||||
</Link>
|
||||
<Link
|
||||
href="/de"
|
||||
className="hover:text-white transition-colors"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: locale,
|
||||
to: 'de',
|
||||
location: 'footer',
|
||||
})
|
||||
}
|
||||
>
|
||||
Deutsch
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand & Quality Sub-Footer */}
|
||||
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
||||
<div>
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
target: 'mintel_agency',
|
||||
location: 'sub_footer',
|
||||
})
|
||||
}
|
||||
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
Website entwickelt von Marc Mintel
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
||||
<div className="flex items-center gap-1.5" title="SSL Secured">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
<span>SSL Secured</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="Green Hosting">
|
||||
<Leaf className="w-3.5 h-3.5" />
|
||||
<span>Green Hosting</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>DSGVO Compliant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="WCAG">
|
||||
<Accessibility className="w-3.5 h-3.5" />
|
||||
<span>WCAG</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
<span>PageSpeed 90+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
134
components/FooterBrochureForm.tsx
Normal file
134
components/FooterBrochureForm.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FooterBrochureForm({ className }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success) {
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'footer_inline',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'success') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-1">
|
||||
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">
|
||||
{locale === 'de'
|
||||
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||
: 'We have just sent the catalog to your email.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||
{t('ctaTitle')}
|
||||
</h4>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
className={cn(
|
||||
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||
phase === 'loading'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{phase === 'error' && err && (
|
||||
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,25 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from './ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { cn } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import { Search } from 'lucide-react';
|
||||
import { AISearchResults } from './search/AISearchResults';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations('Navigation');
|
||||
const pathname = usePathname();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
const currentLocale = pathname.split('/')[1] || 'en';
|
||||
|
||||
|
||||
// Check if homepage
|
||||
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
|
||||
|
||||
@@ -29,179 +36,457 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent scroll when mobile menu is open
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus trap logic
|
||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
|
||||
if (focusableElements && focusableElements.length > 0) {
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
const handleTabKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTabKey);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
// Focus the first element when menu opens
|
||||
setTimeout(() => firstElement.focus(), 100);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleTabKey);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
// Function to get path for a different locale
|
||||
|
||||
// Function to get path for a different locale with segment translation
|
||||
const getPathForLocale = (newLocale: string) => {
|
||||
const segments = pathname.split('/');
|
||||
const originLocale = segments[1] || 'en';
|
||||
|
||||
// Translation map for localized URL segments
|
||||
const segmentMap: Record<string, Record<string, string>> = {
|
||||
de: {
|
||||
produkte: 'products',
|
||||
kontakt: 'contact',
|
||||
impressum: 'legal-notice',
|
||||
datenschutz: 'privacy-policy',
|
||||
agbs: 'terms',
|
||||
niederspannungskabel: 'low-voltage-cables',
|
||||
mittelspannungskabel: 'medium-voltage-cables',
|
||||
hochspannungskabel: 'high-voltage-cables',
|
||||
solarkabel: 'solar-cables',
|
||||
},
|
||||
en: {
|
||||
products: 'produkte',
|
||||
contact: 'kontakt',
|
||||
'legal-notice': 'impressum',
|
||||
'privacy-policy': 'datenschutz',
|
||||
terms: 'agbs',
|
||||
'low-voltage-cables': 'niederspannungskabel',
|
||||
'medium-voltage-cables': 'mittelspannungskabel',
|
||||
'high-voltage-cables': 'hochspannungskabel',
|
||||
'solar-cables': 'solarkabel',
|
||||
},
|
||||
};
|
||||
|
||||
// Replace the locale segment
|
||||
segments[1] = newLocale;
|
||||
return segments.join('/');
|
||||
|
||||
// Translate other segments if they exist in our map
|
||||
const translatedSegments = segments.map((segment, index) => {
|
||||
if (index <= 1) return segment; // Skip empty and locale segments
|
||||
|
||||
const mapping = segmentMap[originLocale as keyof typeof segmentMap];
|
||||
return mapping && mapping[segment] ? mapping[segment] : segment;
|
||||
});
|
||||
|
||||
return translatedSegments.join('/');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ label: t('home'), href: '/' },
|
||||
{ label: t('team'), href: '/team' },
|
||||
{ label: t('products'), href: '/products' },
|
||||
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
|
||||
{ label: t('blog'), href: '/blog' },
|
||||
];
|
||||
|
||||
const headerClass = cn(
|
||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p",
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both',
|
||||
{
|
||||
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
|
||||
}
|
||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const textColorClass = "text-white";
|
||||
const logoSrc = "/logo-white.svg";
|
||||
const textColorClass = 'text-white';
|
||||
const logoSrc = '/logo-white.svg';
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={headerClass}>
|
||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||
<Link href={`/${currentLocale}`} className="flex-shrink-0 group touch-target">
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||
<Link
|
||||
href={`/${currentLocale}`}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: 'home_logo',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={t('home')}
|
||||
width={120}
|
||||
height={120}
|
||||
style={{ width: 'auto' }}
|
||||
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
|
||||
priority
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 md:gap-12">
|
||||
<nav className="hidden lg:flex items-center space-x-10">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
"hover:text-accent font-bold transition-all text-lg tracking-tight relative group"
|
||||
)}
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
|
||||
>
|
||||
{item.label}
|
||||
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-300 group-hover:w-full rounded-full" />
|
||||
</Link>
|
||||
{(() => {
|
||||
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(fullHref);
|
||||
return (
|
||||
<Link
|
||||
href={fullHref}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'header_nav',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
textColorClass,
|
||||
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
|
||||
isActive && 'text-accent',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
|
||||
isActive ? 'w-full' : 'w-0 group-hover:w-full',
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={cn("hidden lg:flex items-center space-x-8", textColorClass)}>
|
||||
<div className="flex items-center space-x-4 text-sm font-extrabold tracking-widest uppercase">
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
<div className="w-px h-4 bg-current opacity-20" />
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl"
|
||||
<div
|
||||
className={cn(
|
||||
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both',
|
||||
textColorClass,
|
||||
)}
|
||||
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
|
||||
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'en',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-current opacity-30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
|
||||
type: 'language',
|
||||
from: currentLocale,
|
||||
to: 'de',
|
||||
location: 'header',
|
||||
})
|
||||
}
|
||||
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
className="hover:text-accent transition-colors p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="white"
|
||||
size="md"
|
||||
className="px-8 shadow-xl hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: t('contact'),
|
||||
location: 'header_cta',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 backdrop-blur-md border border-white/20 z-50", textColorClass)}
|
||||
<button
|
||||
className={cn(
|
||||
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
|
||||
textColorClass,
|
||||
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
|
||||
)}
|
||||
aria-label={t('toggleMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => {
|
||||
const newState = !isMobileMenuOpen;
|
||||
setIsMobileMenuOpen(newState);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: newState ? 'open' : 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="w-7 h-7 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div className={cn(
|
||||
"fixed inset-0 bg-primary-dark z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col",
|
||||
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none"
|
||||
)}>
|
||||
<div className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||
isMobileMenuOpen
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||
)}
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
inert={isMobileMenuOpen ? undefined : true}
|
||||
>
|
||||
{/* Close Button inside overlay */}
|
||||
<div className="flex justify-end p-6 pt-8">
|
||||
<button
|
||||
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||
aria-label={t('toggleMenu')}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
type: 'mobile_menu',
|
||||
action: 'close',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.href}
|
||||
className={cn(
|
||||
'transition-all duration-500 transform',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||
aria-current={
|
||||
(
|
||||
item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||
)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
location: 'mobile_menu',
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"text-3xl font-extrabold text-white hover:text-accent transition-all transform",
|
||||
isMobileMenuOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0"
|
||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||
(item.href === '/'
|
||||
? pathname === `/${currentLocale}` || pathname === '/'
|
||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||
)}
|
||||
style={{ transitionDelay: `${idx * 100}ms` }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className={cn(
|
||||
"pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500 delay-500",
|
||||
isMobileMenuOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0"
|
||||
)}>
|
||||
<div className="flex items-center space-x-8 text-xl font-extrabold tracking-widest uppercase text-white">
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||
)}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||
>
|
||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('en')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
<div className="w-px h-6 bg-white/20" />
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/30" />
|
||||
<div>
|
||||
<Link
|
||||
href={getPathForLocale('de')}
|
||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
href={`/${currentLocale}/contact`}
|
||||
variant="accent"
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs">
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="w-full max-w-xs py-6 text-xl shadow-2xl"
|
||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||
>
|
||||
{t('contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bottom Branding */}
|
||||
<div className="p-12 flex justify-center opacity-20">
|
||||
<div
|
||||
className={cn(
|
||||
'p-12 flex justify-center transition-all duration-700',
|
||||
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||
)}
|
||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||
>
|
||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
51
components/JsonLd.tsx
Normal file
51
components/JsonLd.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Thing, WithContext } from 'schema-dts';
|
||||
|
||||
interface JsonLdProps {
|
||||
id?: string;
|
||||
data?: WithContext<Thing> | WithContext<Thing>[];
|
||||
}
|
||||
|
||||
export default function JsonLd({ id, data }: JsonLdProps) {
|
||||
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
||||
const schemaData = data || [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
logo: 'https://klz-cables.com/logo-blue.svg',
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables',
|
||||
],
|
||||
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://klz-cables.com/search?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<script
|
||||
id={id}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(schemaData),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
components/LeafletMap.tsx
Normal file
66
components/LeafletMap.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix for default marker icon in Leaflet with Next.js
|
||||
if (typeof window !== 'undefined') {
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
}
|
||||
|
||||
interface LeafletMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [lat, lng],
|
||||
zoom: 15,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
// Add tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([lat, lng]).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
const popupContent = `
|
||||
<div class="text-primary font-bold">KLZ Cables</div>
|
||||
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [lat, lng, address]);
|
||||
|
||||
return <div ref={mapRef} className="h-full w-full z-0" />;
|
||||
}
|
||||
262
components/Lightbox.tsx
Normal file
262
components/Lightbox.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
interface LightboxProps {
|
||||
isOpen: boolean;
|
||||
images: string[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Lightbox({ isOpen, images, initialIndex, onClose }: LightboxProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(index: number | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (index !== null) {
|
||||
params.set('photo', index.toString());
|
||||
} else {
|
||||
params.delete('photo');
|
||||
}
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
const next = prev === 0 ? images.length - 1 : prev - 1;
|
||||
updateUrl(next);
|
||||
return next;
|
||||
});
|
||||
}, [images.length, updateUrl]);
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
const next = prev === images.length - 1 ? 0 : prev + 1;
|
||||
updateUrl(next);
|
||||
return next;
|
||||
});
|
||||
}, [images.length, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const photoParam = searchParams.get('photo');
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
updateUrl(null);
|
||||
onClose();
|
||||
}, [updateUrl, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updateUrl(currentIndex);
|
||||
}
|
||||
}, [isOpen, currentIndex, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture previous focus
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus close button on open
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
|
||||
// Focus Trap
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||
document.querySelector('[role="dialog"]')?.contains(el),
|
||||
);
|
||||
|
||||
if (modalElements.length === 0) return;
|
||||
|
||||
const firstElement = modalElements[0] as HTMLElement;
|
||||
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
|
||||
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
|
||||
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = originalHtmlStyle;
|
||||
document.body.style.overflow = originalBodyStyle;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<m.button
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
ref={closeButtonRef}
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
||||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
||||
</div>
|
||||
</m.button>
|
||||
|
||||
<m.button
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
onClick={prevImage}
|
||||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||||
‹
|
||||
</span>
|
||||
</m.button>
|
||||
|
||||
<m.button
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
onClick={nextImage}
|
||||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||||
›
|
||||
</span>
|
||||
</m.button>
|
||||
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
|
||||
>
|
||||
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
||||
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="relative w-full h-full"
|
||||
>
|
||||
<Image
|
||||
src={images[currentIndex]}
|
||||
alt={`Gallery image ${currentIndex + 1}`}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
||||
unoptimized
|
||||
/>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
||||
|
||||
{/* Premium Reflection: Subtle gradient to give material feel */}
|
||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||||
</div>
|
||||
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<div className="h-px w-12 bg-white/20" />
|
||||
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
|
||||
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
||||
</div>
|
||||
<div className="h-px w-12 bg-white/20" />
|
||||
</m.div>
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
185
components/OGImageTemplate.tsx
Normal file
185
components/OGImageTemplate.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OGImageTemplateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
mode?: 'dark' | 'light' | 'image';
|
||||
}
|
||||
|
||||
export function OGImageTemplate({
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
image,
|
||||
mode = 'dark',
|
||||
}: OGImageTemplateProps) {
|
||||
const primaryBlue = '#001a4d';
|
||||
const accentGreen = '#82ed20';
|
||||
const saturatedBlue = '#011dff';
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Background Image with Overlay */}
|
||||
{image && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
width="1200"
|
||||
height="630"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Brand Accent (Top Right) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: accentGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/ObfuscatedEmail.tsx
Normal file
39
components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedEmailProps {
|
||||
email: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect email addresses from simple spambots.
|
||||
* It uses client-side mounting to render the actual email address,
|
||||
* making it harder for static crawlers to harvest.
|
||||
*/
|
||||
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Once mounted on the client, render the real mailto link
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={className}>
|
||||
{children || email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
42
components/ObfuscatedPhone.tsx
Normal file
42
components/ObfuscatedPhone.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ObfuscatedPhoneProps {
|
||||
phone: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that helps protect phone numbers from simple spambots.
|
||||
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||
*/
|
||||
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Format phone number for tel: link (remove spaces, etc.)
|
||||
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||
|
||||
if (!mounted) {
|
||||
// Show a placeholder or obscured version during SSR
|
||||
// e.g. +49 881 925 [at] 37298
|
||||
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{children || obscured}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={telLink} className={className}>
|
||||
{children || phone}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1200
components/PayloadRichText.tsx
Normal file
1200
components/PayloadRichText.tsx
Normal file
File diff suppressed because it is too large
Load Diff
81
components/ProductSidebar.tsx
Normal file
81
components/ProductSidebar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
|
||||
return (
|
||||
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
{/* Request Quote Form Card */}
|
||||
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
|
||||
<div className="bg-primary p-6 text-white relative overflow-hidden">
|
||||
{/* Background Accent - Saturated Blue Glow */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
|
||||
|
||||
{/* Product Thumbnail with Reflection */}
|
||||
{productImage && (
|
||||
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
|
||||
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
|
||||
<Image
|
||||
src={productImage.split('?')[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
|
||||
/>
|
||||
{/* Subtle Reflection Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-transparent opacity-30 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-block relative mb-2">
|
||||
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
|
||||
{t('requestQuote')}
|
||||
</h3>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-3 -bottom-3 left-0 text-accent/80"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white/60 text-xs md:text-sm m-0 mt-2 leading-relaxed font-medium max-w-[90%]">
|
||||
{t('requestQuoteDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-neutral-light/50">
|
||||
<RequestQuoteForm productName={productName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,37 @@ import React from 'react';
|
||||
interface ProductTabsProps {
|
||||
children: React.ReactNode;
|
||||
technicalData?: React.ReactNode;
|
||||
sidebar?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProductTabs({ children, technicalData }: ProductTabsProps) {
|
||||
export default function ProductTabs({ children, technicalData, sidebar }: ProductTabsProps) {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="prose max-w-none">
|
||||
{children}
|
||||
<div className="space-y-24">
|
||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
{sidebar && (
|
||||
<div className="lg:hidden mb-12">
|
||||
{sidebar}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sidebar && (
|
||||
<div className="hidden lg:block sticky top-32 w-[350px] xl:w-[400px] flex-shrink-0">
|
||||
{sidebar}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{technicalData && (
|
||||
<div className="pt-8 border-t border-neutral-dark">
|
||||
<h2 className="text-2xl font-bold text-primary-dark mb-6">Technical Specifications</h2>
|
||||
<div className="pt-24 border-t-2 border-neutral-dark/5">
|
||||
<div className="mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">Technical Specifications</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<div className="not-prose">
|
||||
{technicalData}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -21,78 +25,168 @@ interface ProductTechnicalDataProps {
|
||||
}
|
||||
|
||||
export default function ProductTechnicalData({ data }: ProductTechnicalDataProps) {
|
||||
const t = useTranslations('Products');
|
||||
const [expandedTables, setExpandedTables] = useState<Record<number, boolean>>({});
|
||||
|
||||
if (!data) return null;
|
||||
const { technicalItems = [], voltageTables = [] } = data;
|
||||
|
||||
const toggleTable = (idx: number) => {
|
||||
setExpandedTables((prev) => ({
|
||||
...prev,
|
||||
[idx]: !prev[idx],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
{technicalItems.length > 0 && (
|
||||
<div className="bg-neutral-light p-6 rounded-lg shadow-sm">
|
||||
<h3 className="text-xl font-semibold mb-4">General Data</h3>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col border-b border-neutral-dark pb-2 last:border-0">
|
||||
<dt className="text-sm text-text-secondary">{item.label}</dt>
|
||||
<dd className="text-base font-medium text-text-primary">
|
||||
{item.value} {item.unit && <span className="text-sm text-text-secondary ml-1">{item.unit}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voltageTables.map((table, idx) => (
|
||||
<div key={idx} className="bg-neutral-light p-6 rounded-lg shadow-sm overflow-hidden">
|
||||
<h3 className="text-xl font-semibold mb-4">
|
||||
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
|
||||
{table.metaItems.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-6 bg-neutral p-4 rounded">
|
||||
{table.metaItems.map((item, mIdx) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-xs text-text-secondary uppercase tracking-wider">{item.label}</dt>
|
||||
<dd className="font-medium">{item.value} {item.unit}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{voltageTables.map((table, idx) => {
|
||||
const isExpanded = expandedTables[idx];
|
||||
const hasManyRows = table.rows.length > 10;
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-neutral-dark">
|
||||
<thead className="bg-neutral">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider sticky left-0 bg-neutral z-10">
|
||||
Configuration
|
||||
</th>
|
||||
{table.columns.map((col, cIdx) => (
|
||||
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider whitespace-nowrap">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-neutral-dark">
|
||||
{table.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-neutral-light transition-colors">
|
||||
<td className="px-3 py-3 text-sm font-medium text-text-primary sticky left-0 bg-white z-10 whitespace-nowrap">
|
||||
{row.configuration}
|
||||
</td>
|
||||
{row.cells.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="px-3 py-3 text-sm text-text-secondary whitespace-nowrap">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
{table.voltageLabel !== 'Voltage unknown' &&
|
||||
table.voltageLabel !== 'Spannung unbekannt'
|
||||
? table.voltageLabel
|
||||
: 'Technical Specifications'}
|
||||
</h3>
|
||||
|
||||
{table.metaItems.length > 0 && (
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||
{table.metaItems.map((item, mIdx) => (
|
||||
<div key={mIdx}>
|
||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="font-bold text-primary">
|
||||
{item.value} {item.unit}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Scroll hint gradient on right edge for mobile */}
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||
<div
|
||||
id={`voltage-table-${idx}`}
|
||||
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
|
||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
||||
}`}
|
||||
>
|
||||
<table className="min-w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
|
||||
>
|
||||
Config.
|
||||
</th>
|
||||
{table.columns.map((col, cIdx) => (
|
||||
<th
|
||||
key={cIdx}
|
||||
scope="col"
|
||||
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-dark/5">
|
||||
{table.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-neutral-light/50 transition-colors group">
|
||||
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
|
||||
{row.configuration}
|
||||
</td>
|
||||
{row.cells.map((cell: any, cellIdx: number) => (
|
||||
<td
|
||||
key={cellIdx}
|
||||
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
|
||||
>
|
||||
{typeof cell === 'object' && cell !== null && 'value' in cell
|
||||
? cell.value
|
||||
: cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isExpanded && hasManyRows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white via-white/80 to-transparent z-20 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasManyRows && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<button
|
||||
onClick={() => toggleTable(idx)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`voltage-table-${idx}`}
|
||||
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
{isExpanded ? t('showLess') : t('showMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
39
components/RelatedProductLink.tsx
Normal file
39
components/RelatedProductLink.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface RelatedProductLinkProps {
|
||||
href: string;
|
||||
productSlug: string;
|
||||
productTitle: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RelatedProductLink({
|
||||
href,
|
||||
productSlug,
|
||||
productTitle,
|
||||
children,
|
||||
className,
|
||||
}: RelatedProductLinkProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
|
||||
product_id: productSlug,
|
||||
product_name: productTitle,
|
||||
location: 'related_products',
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { getAllProducts } from '@/lib/mdx';
|
||||
import { getAllProducts } from '@/lib/products';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Image from 'next/image';
|
||||
import { RelatedProductLink } from './RelatedProductLink';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
|
||||
interface RelatedProductsProps {
|
||||
currentSlug: string;
|
||||
@@ -9,22 +10,53 @@ interface RelatedProductsProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
export default async function RelatedProducts({
|
||||
currentSlug,
|
||||
categories,
|
||||
locale,
|
||||
}: RelatedProductsProps) {
|
||||
const products = await getAllProducts(locale);
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
const productsSlug = await mapFileSlugToTranslated('products', locale);
|
||||
|
||||
// Filter products: same category, not current product
|
||||
const related = allProducts
|
||||
.filter(p =>
|
||||
p.slug !== currentSlug &&
|
||||
p.frontmatter.categories.some(cat => categories.includes(cat))
|
||||
const related = products
|
||||
.filter(
|
||||
(p) =>
|
||||
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
|
||||
)
|
||||
.slice(0, 3); // Limit to 3 for better spacing
|
||||
|
||||
if (related.length === 0) return null;
|
||||
|
||||
// Pre-calculate translated slugs for related products
|
||||
const relatedWithTranslatedSlugs = await Promise.all(
|
||||
related.map(async (product) => {
|
||||
// Find the category slug for the link
|
||||
const categorySlugs = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const catFileSlug =
|
||||
categorySlugs.find((slug) => {
|
||||
return product.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
|
||||
);
|
||||
}) || 'low-voltage-cables';
|
||||
|
||||
const catSlug = await mapFileSlugToTranslated(catFileSlug, locale);
|
||||
|
||||
return {
|
||||
...product,
|
||||
catSlug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-32 pt-32 border-t border-neutral-dark/10">
|
||||
<div className="">
|
||||
<div className="flex items-end justify-between mb-12">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-primary tracking-tight mb-4">
|
||||
@@ -35,21 +67,20 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{related.map((product) => {
|
||||
// Find the category slug for the link
|
||||
const catSlug = product.frontmatter.categories[0].toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
{relatedWithTranslatedSlugs.map((product) => {
|
||||
return (
|
||||
<Link
|
||||
key={product.slug}
|
||||
href={`/${locale}/products/${catSlug}/${product.slug}`}
|
||||
<RelatedProductLink
|
||||
key={product.slug}
|
||||
href={`/${locale}/${productsSlug}/${product.catSlug}/${product.slug}`}
|
||||
productSlug={product.slug}
|
||||
productTitle={product.frontmatter.title}
|
||||
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
|
||||
>
|
||||
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
|
||||
{product.frontmatter.images?.[0] ? (
|
||||
<>
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
src={product.frontmatter.images[0].split('?')[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"
|
||||
@@ -64,8 +95,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
|
||||
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
||||
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
@@ -77,12 +111,23 @@ export default async function RelatedProducts({ currentSlug, categories, locale
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-4 h-4 ml-2 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</RelatedProductLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Input, Textarea, Button } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||
|
||||
interface RequestQuoteFormProps {
|
||||
productName: string;
|
||||
@@ -9,40 +13,107 @@ interface RequestQuoteFormProps {
|
||||
|
||||
export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) {
|
||||
const t = useTranslations('Products.form');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [email, setEmail] = useState('');
|
||||
const [request, setRequest] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
const handleFocus = (fieldId: string) => {
|
||||
// Initial form start
|
||||
if (!hasStarted) {
|
||||
setHasStarted(true);
|
||||
trackEvent(AnalyticsEvents.FORM_START, {
|
||||
form_id: 'quote_request_form',
|
||||
form_name: 'Product Quote Inquiry',
|
||||
product_name: productName,
|
||||
});
|
||||
}
|
||||
|
||||
// Field-level transparency
|
||||
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
|
||||
form_id: 'quote_request_form',
|
||||
field_id: fieldId,
|
||||
product_name: productName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus('submitting');
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const formData = new FormData();
|
||||
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
|
||||
formData.append('email', email);
|
||||
formData.append('message', request);
|
||||
formData.append('productName', productName);
|
||||
|
||||
// Here you would typically send the data to your backend
|
||||
console.log('Form submitted:', { productName, email, request });
|
||||
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'product_quote',
|
||||
product_name: productName,
|
||||
email: email,
|
||||
});
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
} else {
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'quote_request_form',
|
||||
product_name: productName,
|
||||
error: result.error || 'submission_failed',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
trackEvent(AnalyticsEvents.FORM_ERROR, {
|
||||
form_id: 'quote_request_form',
|
||||
product_name: productName,
|
||||
error: (error as Error).message || 'unexpected_error',
|
||||
});
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const emailId = React.useId();
|
||||
const requestId = React.useId();
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-6 rounded-[32px] text-center animate-fade-in">
|
||||
<div className="w-12 h-12 bg-accent rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg shadow-accent/20">
|
||||
<svg className="w-6 h-6 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div
|
||||
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-extrabold mb-2 tracking-tight">{t('successTitle')}</h3>
|
||||
<p className="text-text-secondary text-sm leading-relaxed mb-6">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||
{t('successTitle')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('successDesc', { productName })}
|
||||
</p>
|
||||
<button
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="inline-flex items-center text-[10px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
|
||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
|
||||
>
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent transition-colors pb-1">
|
||||
{t('sendAnother')}
|
||||
@@ -52,59 +123,143 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||
{t('errorTitle') || 'Submission Failed'}
|
||||
</h3>
|
||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{t('tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="space-y-2 !mt-0">
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={emailId} className="sr-only">
|
||||
{t('email')}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
id={emailId}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-neutral-light/50 border border-neutral-dark/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent focus:bg-white transition-all duration-300 placeholder:text-neutral-dark/40 text-sm"
|
||||
onFocus={() => handleFocus('quote-email')}
|
||||
placeholder={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<textarea
|
||||
id="request"
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={requestId} className="sr-only">
|
||||
{t('message')}
|
||||
</label>
|
||||
<Textarea
|
||||
id={requestId}
|
||||
required
|
||||
rows={4}
|
||||
rows={3}
|
||||
value={request}
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-neutral-light/50 border border-neutral-dark/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent focus:bg-white transition-all duration-300 placeholder:text-neutral-dark/40 text-sm resize-none"
|
||||
onFocus={() => handleFocus('quote-request')}
|
||||
placeholder={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
<div className="space-y-2 !mt-0">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full bg-primary text-white font-bold py-3.5 px-6 rounded-xl hover:bg-primary-dark hover:shadow-lg active:scale-[0.98] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
|
||||
className="w-full py-2 rounded-lg flex items-center justify-center gap-2 group !mt-0"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-3 w-3 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-sm">{t('submitting')}</span>
|
||||
<span className="text-xs">{t('submitting')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">{t('submit')}</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<span className="text-xs">{t('submit')}</span>
|
||||
<svg
|
||||
className="w-3 h-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[8px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2">
|
||||
</Button>
|
||||
|
||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,13 +25,14 @@ export default function Reveal({ children, className, threshold = 0.1, delay = 0
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
const currentRef = ref.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current);
|
||||
if (currentRef) {
|
||||
observer.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
@@ -10,22 +12,23 @@ interface ScribbleProps {
|
||||
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
|
||||
if (variant === 'circle') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="0 0 800 350"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 800 350"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
style={{ animationDuration: '1.8s' }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
pathLength="1"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
|
||||
strokeLinejoin="miter"
|
||||
fillOpacity="0"
|
||||
strokeMiterlimit="4"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth="20"
|
||||
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
|
||||
/>
|
||||
</svg>
|
||||
@@ -34,18 +37,19 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute pointer-events-none", className)}
|
||||
role="presentation"
|
||||
viewBox="-400 -55 730 60"
|
||||
<svg
|
||||
className={cn('absolute pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
viewBox="-400 -55 730 60"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
style={{ animationDuration: '1.8s' }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
pathLength="1"
|
||||
strokeWidth="20"
|
||||
<path
|
||||
className="animate-draw-stroke"
|
||||
pathLength="1"
|
||||
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
|
||||
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
|
||||
stroke={color}
|
||||
strokeWidth="20"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
16
components/SkipLink.tsx
Normal file
16
components/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function SkipLink() {
|
||||
const t = useTranslations('Navigation');
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="fixed -top-full left-4 z-[100] px-6 py-3 bg-white text-primary-dark font-bold rounded-lg shadow-xl outline-none ring-2 ring-accent transition-all duration-300 focus:top-4"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -4,17 +4,32 @@ import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
|
||||
// Minimal client-side hook that sends Umami pageviews on route changes.
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* This component handles navigation events for the Umami analytics service.
|
||||
*
|
||||
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||
* so it's no longer needed as a prop here.
|
||||
*/
|
||||
export default function AnalyticsProvider() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
// Track pageview with the full URL
|
||||
// The service will relay this to our internal proxy which injects the Website ID
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
// Services like logger are already sub-initialized in getAppServices()
|
||||
// so we don't need to log here manually.
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
43
components/analytics/AnalyticsShell.tsx
Normal file
43
components/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
||||
ssr: false,
|
||||
});
|
||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AnalyticsShell() {
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Disable analytics in CI to prevent console noise/score penalties
|
||||
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
||||
} else {
|
||||
const timer = setTimeout(() => setShouldLoad(true), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!shouldLoad) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DynamicAnalyticsProvider />
|
||||
<DynamicScrollDepthTracker />
|
||||
<DynamicWebVitalsTracker />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
53
components/analytics/BlogEngagementTracker.tsx
Normal file
53
components/analytics/BlogEngagementTracker.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics-events';
|
||||
|
||||
interface BlogEngagementTrackerProps {
|
||||
title: string;
|
||||
slug: string;
|
||||
category?: string;
|
||||
readingTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlogEngagementTracker
|
||||
* Tracks reading time and article completion.
|
||||
*/
|
||||
export default function BlogEngagementTracker({
|
||||
title,
|
||||
slug,
|
||||
category,
|
||||
readingTime,
|
||||
}: BlogEngagementTrackerProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
// Article start
|
||||
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
|
||||
title,
|
||||
slug,
|
||||
category,
|
||||
estimated_reading_time: readingTime,
|
||||
location: 'blog_post_pdp',
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return () => {
|
||||
const dwellTime = Math.round((Date.now() - startTime) / 1000);
|
||||
|
||||
// We only consider it a "read" if they stay a reasonable amount of time
|
||||
// or if they scroll (covered by ScrollDepthTracker)
|
||||
trackEvent('blog_dwell_time', {
|
||||
title,
|
||||
slug,
|
||||
seconds: dwellTime,
|
||||
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
|
||||
});
|
||||
};
|
||||
}, [title, slug, category, readingTime, trackEvent]);
|
||||
|
||||
return null;
|
||||
}
|
||||
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics-events';
|
||||
|
||||
export default function ClientNotFoundTracker({ path }: { path: string }) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
type: '404_not_found',
|
||||
path,
|
||||
});
|
||||
|
||||
import('@sentry/nextjs').then((Sentry) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('status_code', '404');
|
||||
scope.setTag('path', path);
|
||||
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
||||
});
|
||||
});
|
||||
}, [trackEvent, path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
1174
components/analytics/EXAMPLES.md
Normal file
1174
components/analytics/EXAMPLES.md
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user