mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
825 Commits
v2.8.0
...
f91721c73e
| Author | SHA1 | Date | |
|---|---|---|---|
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 | |||
| f46cc8a98e | |||
| 1378c95078 | |||
| bb477645a3 | |||
| e65acb3de7 | |||
| 3c040fab11 | |||
| 49b3af8b0d | |||
| 093e069ccc | |||
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 | |||
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a | |||
| 232dc78cc9 | |||
| d2c44380a4 | |||
| 2f9d7adf4a | |||
| ba4a64241b | |||
| ee14f706c8 | |||
| 1cc43f63df | |||
| 3450bd59f8 | |||
| 457d436cf6 | |||
| 1127efb9c4 | |||
| 0a98d3c2e7 | |||
| 5eaf7492dc | |||
| ee31c78db8 | |||
| edf14e2ebc | |||
| 2aad8f465c | |||
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf | |||
| b3f2f7308a | |||
| af9b31c1ff | |||
| d7d1493289 | |||
| 54e042b736 | |||
| 0ba31847eb | |||
| 26ab39dc21 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 1559ed12bd | |||
| c1b9d11173 | |||
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 | |||
| 3d887f15ab | |||
| 82bb08e685 | |||
| 4f3368502a | |||
| 0d534f13cf | |||
| ffa10cac65 | |||
| b85f8c5bca | |||
| da39b570eb | |||
| 151950d08a | |||
| e562d7a7ec | |||
| d0383c06c3 | |||
| 5978eec270 | |||
| 242d1bf8d4 | |||
| 4a8260dfbc | |||
| 076a752ee7 | |||
| 545d62c400 | |||
| f8542b4d87 | |||
| c2fea0a26a | |||
| 25bdf56d16 | |||
| d07b508a77 | |||
| 9ddb2f4cd0 | |||
| 5691149a82 | |||
| 4974013995 | |||
| bc192d3106 | |||
| 4db6cbef22 | |||
| f79385cf2a | |||
| db2c11e4a5 | |||
| e57c6773fc | |||
| 4bdc032f97 | |||
| 777b68f87b | |||
| 66a7de09c1 | |||
| a19ae9e653 | |||
| 38f4c9aecb | |||
| 802d78b577 | |||
| 3f61e1ca38 | |||
| 8e04deb0f5 | |||
| 160bd02f13 | |||
| 68a3036909 | |||
| ec4aaa628f | |||
| 2c0894b330 | |||
| bd2bdebc33 | |||
| 2857ff594c | |||
| 4f01a10277 | |||
| ee805369d1 | |||
| 6a718fccea | |||
| 01ed60e2d5 | |||
| 8042db8d7a | |||
| 21649d3cf0 | |||
| b9395e1e36 | |||
| 10d1f8d428 | |||
| 0c00f8e0b3 | |||
| 71637a8483 | |||
| 189b257254 | |||
| cd2f50bc89 | |||
| 530550455d | |||
| 9a31fcac7b | |||
| 677157de1d | |||
| b5b1d32b31 | |||
| ae4dfc48cc | |||
| 3b487519a5 | |||
| 1425c4e05b | |||
| a84aedc3b4 | |||
| 4b7ba6cb3f | |||
| 5952e02971 | |||
| 8cd5aa0d23 | |||
| c0aa252f9a | |||
| 8a58ce51c0 | |||
| 9c2decb095 | |||
| 5e9c8d2c43 | |||
| 39f13881c5 | |||
| 3b94727c07 | |||
| 4a5a461d25 | |||
| 1963573db4 | |||
| 5046e1a2e0 | |||
| a1f3b4476e | |||
| 8defc90e95 | |||
| b2a39a3071 | |||
| 21511c2f68 | |||
| 0e5c819f7c | |||
| 0f44d7d264 | |||
| e078a9d9e1 | |||
| fef12b0e8b | |||
| df075630fb | |||
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb | |||
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 | |||
| d80bbd5bed | |||
| 293506217e | |||
| 9739542a3a | |||
| 9f3a88223d | |||
| 409a63633c | |||
| 125436fa87 | |||
| 975846c236 | |||
| 7befb7d555 | |||
| 099255761c | |||
| c8fc21b8bd | |||
| 9186b8c850 | |||
| e38c5fed44 | |||
| 3b069bc543 | |||
| 618b1b8697 | |||
| e45a0efce3 | |||
| 597a5f7a1d | |||
| 42c216b00b | |||
| f3751ab9aa | |||
| 9e8d101d63 | |||
| 5656731850 | |||
| 7c4ac70db3 | |||
| bfe84b3016 | |||
| f349e567f8 | |||
| ff434f4515 | |||
| 0c2e0cad5c | |||
| 326f9c0823 | |||
| 6df5edfbdb | |||
| 5023406717 | |||
| 5be805910c | |||
| 191d59166c | |||
| 09948dd804 | |||
| 875c91e5ff | |||
| 801ffbfb7b | |||
| a1a7795945 | |||
| 4491b109ee | |||
| 9789c51d4f | |||
| 4362406e74 | |||
| 04c58e6e0f | |||
| ba86de3656 | |||
| 607498cabe | |||
| 35321076cf | |||
| a5a7ee9916 | |||
| 33bb2c6863 | |||
| b0d97707ba | |||
| f0e8cf8257 | |||
| 280fcecabb | |||
| a07e76c740 | |||
| f35c503658 | |||
| 53c44fa8ba | |||
| ee3966d6c8 | |||
| 06f68a462b | |||
| 0104ecfee8 | |||
| a3f368d547 | |||
| a438652a50 | |||
| a8899a551b | |||
| f7da46c785 | |||
| 14b305c600 | |||
| be71425bb7 | |||
| cf4052307d | |||
| 4e3b27c712 | |||
| 85d72c831d | |||
| bb3543efa6 | |||
| 0e70857d78 | |||
| d3b5ca451b | |||
| b194e8317d | |||
| bb8783d217 | |||
| 8c7567faf3 | |||
| 1268d3e7b1 | |||
| 80e1574c26 | |||
| 9cbe20cbde | |||
| fc6430d5ad | |||
| d6aa18c063 | |||
| 563b338ee3 | |||
| 5ea4095beb | |||
| 81d3d6cc7d | |||
| e695e0f62d | |||
| 00e96baf0e | |||
| 1a3407a218 | |||
| efeff0ba9e | |||
| b3571f391a | |||
| 65931a1777 | |||
| d04a4bcbf8 | |||
| 1d4f18bdf9 | |||
| bb160a4010 | |||
| ff2b33d83b | |||
| 6a23118342 | |||
| 13af757ad1 | |||
| bae24ad4af | |||
| f60e611577 | |||
| 5b99efce06 | |||
| eb8ec8d793 | |||
| f4b07422ac | |||
| 137ae27cb8 | |||
| d3eab7d973 | |||
| bf2c6d35b5 | |||
| 0a408c21ac | |||
| 98340aa855 | |||
| 714e2ad703 | |||
| aa32b1f372 | |||
| 375ae53566 | |||
| f686902cd3 | |||
| b0f3440221 | |||
| 707b3f227c | |||
| 24bcf6ded8 | |||
| 240b10a192 | |||
| 88e1d075e0 | |||
| 87de60d8de | |||
| e395935f6a | |||
| 3a52b80e3a | |||
| 7e3cb29c57 | |||
| c60332dcf1 | |||
| 6c253c71c3 | |||
| 33c63d34e7 | |||
| 149aa4c5e2 | |||
| 1f68ba1ea1 | |||
| c0c59b6d80 | |||
| 479ab49d67 | |||
| 1a51f8e3e1 | |||
| 7fca16d866 | |||
| e629548a42 | |||
| c39182616b | |||
| 1d9a6acc01 | |||
| 18da5aed39 | |||
| 60c5755647 | |||
| b84381a8de | |||
| c19e65b46b | |||
| 44f5f7d114 | |||
| f46f484d5f | |||
| bf3649942c | |||
| 91f7c3778f | |||
| abed22661a | |||
| 57503a6a10 | |||
| 34df665944 | |||
| e179769a8f | |||
| 0d7238300e | |||
| e3dea0a3ea | |||
| 6a19807a72 | |||
| 4680aa254d | |||
| 137c6ff9dd | |||
| af789b7f7c | |||
| 0fe1c443e9 | |||
| ecbb1de8de | |||
| 9c42a01391 | |||
| 7abfb4deba | |||
| ad27c5f6be | |||
| 86be4d7997 | |||
| a2c05f3caa | |||
| 62453ebefa | |||
| e198791139 | |||
| e1a7558647 | |||
| 981b667fbb | |||
| 1b45571e63 | |||
| 3ad1bef134 | |||
| 85e017ff85 | |||
| 133676d05b | |||
| f323952012 | |||
| 2215395a26 | |||
| caa9e0503e | |||
| 1d9012d9da | |||
| f67567dbcf | |||
| 344b769583 | |||
| 9f4523a8ce | |||
| efeb22558c | |||
| de157cb87b | |||
| 2d9f545c57 | |||
| 5564bce133 | |||
| 7c2df01a5e | |||
| 1d109435ad | |||
| 47d9cce936 | |||
| bfd2553d1e | |||
| 2b1889b9a9 | |||
| 468035fc3c | |||
| 467d35702b | |||
| d0337b1b6d | |||
| d680cab0f6 | |||
| 4976fe5e7f | |||
| 42c12ea26d | |||
| a6a12acad7 | |||
| 956c4270df | |||
| 13956804c2 | |||
| aa1261e82b | |||
| 38cd318a82 | |||
| eff3fcfe10 | |||
| 0257e0d842 | |||
| 7871c06059 | |||
| bcc37d6b7d | |||
| c96044f4f7 | |||
| 0f6be35870 | |||
| f47852d689 | |||
| 4e683e92ec | |||
| 3b080ac116 | |||
| 0efa316004 | |||
| 7a22d742ab | |||
| a4727c4c53 | |||
| 577f2b05ca | |||
| 1585c472c2 | |||
| dd8d2ae54a | |||
| e3a5bc0f77 | |||
| 535c06bb3f | |||
| 6a632137ed | |||
| f82f00216b | |||
| be248e1ad4 | |||
| abc5ee2aa7 | |||
| e290c7c522 | |||
| f20eb6639f | |||
| d0176d7ed6 | |||
| 8402f3bcfd | |||
| 6caa966a52 | |||
| 098918b416 | |||
| 4670d4914c | |||
| 3ce9962b32 | |||
| 4b1286d53c | |||
| cc2a2ddca3 | |||
| 4ad1ccf5dd | |||
| ac9c5784ee | |||
| cb3aeda8e0 | |||
| 9b1baaf7b8 | |||
| 81a360f9a7 | |||
| a74a6313dd | |||
| 89a109560e | |||
| ce36b550c3 | |||
| 1187883c6b | |||
| cef86cbcd9 | |||
| bf23b2d2f2 | |||
| 4a16442db0 | |||
| 7c0a0d5f39 | |||
| 8f1445e6df | |||
| e91ee04d93 | |||
| 583ac6d4d9 | |||
| 8212f3c023 | |||
| 35d676e76e | |||
| 41f1dd9ce5 | |||
| 5b44fe68b1 | |||
| 54f280c366 | |||
| 3eb0812c97 | |||
| f2908fdd65 | |||
| 830f6c0706 | |||
| 0df90086bf | |||
| 5c0d819fc1 | |||
| 1f3e27765a | |||
| 89c10ccedb | |||
| 91bde5cb5a | |||
| 059a0a24c5 | |||
| 576ad85c08 | |||
| 63784d86a3 | |||
| add979a9f5 | |||
| 4226dd405f | |||
| 28c7013252 | |||
| fa810c3bab | |||
| 5e96c877a6 | |||
| 93d5ab7fcd | |||
| 91c9421b5e | |||
| a565f3c665 | |||
| 78b465a815 | |||
| 6aeec0ead1 | |||
| 3ccafb9a7b | |||
| caa6b7ecca | |||
| 6883f2fdf9 | |||
| 4b0cda41cf | |||
| 1646caa66b | |||
| 39db61cc76 | |||
| 46449d374a | |||
| 978df648eb | |||
| a012dffa22 | |||
| 729526bd34 | |||
| c13b28ae8f | |||
| 306012c4c5 | |||
| ab97e38f68 | |||
| d4bb8be86b | |||
| cbdfe74bb9 | |||
| 2b7057b922 | |||
| bd0b7746ab | |||
| 009b9f838a | |||
| 2d17ec60db | |||
| 9dc91b08a9 | |||
| 955a3cff78 | |||
| 741a8d3f09 | |||
| 525dc6ebd2 | |||
| 8c7d1f8fa6 | |||
| dba655d6e8 | |||
| cb8280249f | |||
| 68b660e547 | |||
| f594cbc21b | |||
| e991f834e2 | |||
| b0633b1d36 | |||
| d8da0fffa5 | |||
| 9e23766b51 | |||
| 8e69ad44f0 | |||
| fd48169219 | |||
| 0e3e6df1f0 | |||
| 9390a2e9c6 | |||
| c96360c7f8 | |||
| 4cd3ec7cc7 | |||
| d9d389d090 | |||
| 504195a324 | |||
| 47b880221d | |||
| 3c31902885 | |||
| 81851d8367 | |||
| 2f4e067a65 | |||
| aacfd24b58 | |||
| 8c8bd5bc37 | |||
| a2359dd769 | |||
| 781861f799 | |||
| b4922322ae | |||
| 5bcadb3cc6 | |||
| 2cc79b3d16 | |||
| c671b5ff17 | |||
| d60ab3672e | |||
| 5271463064 | |||
| 96080e8a03 | |||
| a6ea73eab6 | |||
| 4ba6005ca3 | |||
| c4e6c12282 | |||
| 09ab829b17 | |||
| 66a057a070 | |||
| f2ffea5ba4 | |||
| b0dee4dafb | |||
| c5a6b78c32 | |||
| beb48af8ed | |||
| e2be3ec191 | |||
| 68a1f9683e | |||
| 5c57116a68 | |||
| 48508b9df4 | |||
| c8250256a7 | |||
| 6491e1f986 | |||
| 03757ed0af | |||
| a676dbe881 | |||
| 411d8620ba | |||
| f45f56318a | |||
| 3ae0f3f819 | |||
| 306626ee1c | |||
| 7e0fe3b1b9 | |||
| fdbc015dbf | |||
| 7d8e3912b4 | |||
| 9ebca725ae | |||
| 4105abcd0f | |||
| 9718187490 | |||
| aa0620e01f | |||
| 955776b492 | |||
| 9b11abbf4a | |||
| cc613771fa | |||
| 5cc81ae4b0 | |||
| 94b74f96a3 | |||
| 48bf149d01 | |||
| f3679739d8 | |||
| 38206883ff | |||
| dd21074c27 | |||
| cd5a6c7491 | |||
| 6e6e0a370e | |||
| 83bac11173 | |||
| ecf69225e1 | |||
| c6148ba4f2 | |||
| 9ee5d21c3a | |||
| d5cc2432c4 | |||
| 7f077d949d | |||
| 312bc715bf | |||
| 6ba08352ed | |||
| 58874a1ccb | |||
| 82f08360d7 | |||
| 978d26f36c | |||
| 18eee16d2d | |||
| c274846275 | |||
| 7821993450 | |||
| a9d6ce87c1 | |||
| 67b21d5fe3 | |||
| 8b488efc8e | |||
| 070b75b6be | |||
| 51c4afd5f7 | |||
| 74b3b0f9ae | |||
| 1236f3281d | |||
| 4a0d586768 | |||
| 079964bec8 | |||
| b0b85fff3a | |||
| 0d3a10120a | |||
| b8c3d5b3d1 | |||
| 959015928f | |||
| d8ee545002 | |||
| 78b9536de9 | |||
| 4e4afe2545 | |||
| 38afba0820 | |||
| 81742dbb85 | |||
| 3898e5f7e2 | |||
| 6a36efbf1a | |||
| 991b4065e3 | |||
| c158df1bc5 | |||
| f03705848d | |||
| 0c99eb1d07 | |||
| 7b37d337c1 | |||
| 69ae6f93db | |||
| 71c1683bb3 | |||
| 6df8b2555d | |||
| 16cadeb09e | |||
| fc29c5f7d0 | |||
| 399684cc19 | |||
| a038dbd8da | |||
| f225f45f50 | |||
| 58b7c2e7ac | |||
| b8058a2755 | |||
| aa244dd548 | |||
| 33d8953554 | |||
| c39ae2b965 | |||
| 3413d3f77d | |||
| c9e3185ad0 | |||
| f8cf37a9bd | |||
| 20709d23ee | |||
| e4065c276b | |||
| 11b6974387 | |||
| 554a7d7530 | |||
| 2baf407809 | |||
| 259ff53bfb | |||
| 21063e6230 | |||
| 1285da063e | |||
| 3e9e3fcc9e | |||
| ba4bfc693a | |||
| 179938e904 | |||
| 4e13a59338 | |||
| 2c9e71c91d | |||
| 733567d088 | |||
| 5b25c60b62 | |||
| d7efa9d914 | |||
| c70f5284c7 | |||
| b40bea036f | |||
| 6da7843bf0 | |||
| 9f0ec8199f | |||
| 9bff25558e | |||
| 00b96eb678 | |||
| 3d0249e076 | |||
| 1bddb3c588 | |||
| b26023e32a | |||
| c8421eb1fc | |||
| 8c125738e8 | |||
| 6d92e14515 | |||
| 0b36427c09 | |||
| 1ea0eb9965 | |||
| c4c3ea1e6d | |||
| 43c801232e | |||
| 6825a4a0c1 | |||
| 8a4a8b58be | |||
| be975f38a6 | |||
| fa37d5b3f7 | |||
| 0ddd0c14b2 | |||
| 297cfda32b | |||
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| 3f612c4d26 | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 877e1a09cc | |||
| bca82b3f8c | |||
| 1aea2fcee8 | |||
| 504713d920 | |||
| 50d2a211e5 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 68f0d399ca | |||
| 1305a07502 | |||
| c9dd8e1192 | |||
| 860739b28b | |||
| 2469739bca | |||
| 2197e0e1fd | |||
| 846db9d076 | |||
| a307d8d1c9 | |||
| ae0d48ac83 | |||
| 6400c2d27d | |||
| fc28996420 | |||
| b6686a462f | |||
| 9ddb101135 | |||
| ba737a9920 | |||
| 7d51eadf90 | |||
| 66740887e7 | |||
| 69deaf9969 | |||
| 61a5e42403 | |||
| 07546c4790 | |||
| f4f768a1b3 | |||
| a9c392e26e | |||
| 90af1332e8 | |||
| de4bdb4a99 | |||
| fa25ff29bb | |||
| 21f87d9b91 | |||
| 0115987e52 | |||
| 6c138ca924 | |||
| 1adc2fec86 | |||
| 8c7f8d6ad1 | |||
| 2ae9da3153 | |||
| b4741c31a9 | |||
| 82a3940a2c | |||
| b224f8b713 | |||
| be03fffcae | |||
| 1e27a62b53 | |||
| d418d85d02 | |||
| a7d3f9fc06 | |||
| 7a169d0596 | |||
| cf968969d0 | |||
| c20d0256c8 | |||
| c4236d6737 | |||
| 4b8cfc78b8 | |||
| f7c965bc6b | |||
| 78a91ccb95 | |||
| 8e9f8784dc | |||
| 5be2e9b268 | |||
| 1e44b25a0c |
@@ -2,6 +2,7 @@ node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
client/dist
|
||||
shared/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
@@ -30,3 +31,7 @@ sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Normalize line endings to LF on commit
|
||||
* text=auto eol=lf
|
||||
# Explicitly enforce LF for source files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
@@ -12,6 +12,8 @@ body:
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
@@ -60,6 +62,7 @@ body:
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Proxmox Community Script
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
## Description
|
||||
<!-- What does this PR do? Why? -->
|
||||
|
||||
## Related Issue or Discussion
|
||||
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
|
||||
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
|
||||
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have updated documentation if needed
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Close issues with unchanged bad titles
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
"[bug]", "bug report", "bug", "issue",
|
||||
"help", "question", "test", "...", "untitled"
|
||||
];
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'invalid-title',
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const issue of issues) {
|
||||
const createdAt = new Date(issue.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
const titleLower = issue.title.trim().toLowerCase();
|
||||
|
||||
if (!badTitles.includes(titleLower)) {
|
||||
// Title was fixed — remove the label and move on
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: 'invalid-title',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still a bad title after 24h — close it
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
'## Issue closed',
|
||||
'',
|
||||
'This issue has been automatically closed because the title was not updated within 24 hours.',
|
||||
'',
|
||||
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Close PRs with unchanged wrong base branch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const pull of pulls) {
|
||||
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||
if (hasBypass) continue;
|
||||
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
// Wiki-only PRs are exempt — clear label and skip
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(pull.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
// Base was fixed — remove label and move on
|
||||
if (pull.base.ref !== 'main') {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still targeting main after 24h — close it
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
body: [
|
||||
'## PR closed',
|
||||
'',
|
||||
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
|
||||
'',
|
||||
'Feel free to open a new PR targeting `dev`.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Close untitled issues
|
||||
name: Flag issues with bad titles
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -10,58 +10,83 @@ permissions:
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const badTitles = [
|
||||
"[bug]",
|
||||
"bug report",
|
||||
"bug",
|
||||
"issue",
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request",
|
||||
"[feature]",
|
||||
"[feature request]",
|
||||
"[enhancement]"
|
||||
]
|
||||
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
const badTitles = [
|
||||
"[bug]", "bug report", "bug", "issue",
|
||||
"help", "question", "test", "...", "untitled"
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request", "[feature]", "[feature request]", "[enhancement]"
|
||||
];
|
||||
|
||||
if (badTitles.includes(titleLower)) {
|
||||
// Ensure the label exists
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'invalid-title',
|
||||
});
|
||||
} catch {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'invalid-title',
|
||||
color: 'e4e669',
|
||||
description: 'Issue title does not meet quality standards',
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ['invalid-title'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
|
||||
body: [
|
||||
'## Invalid title',
|
||||
'',
|
||||
`Your issue title \`${title}\` is too generic to be actionable.`,
|
||||
'',
|
||||
'Please edit the title to something descriptive that summarizes the problem — for example:',
|
||||
'> _Map view crashes when zooming in on Safari 17_',
|
||||
'',
|
||||
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
|
||||
body: [
|
||||
'## Wrong place for feature requests',
|
||||
'',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'',
|
||||
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
name: Build & Push Docker Image (Prerelease)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Bump line for next prerelease (auto detects in-flight major)'
|
||||
type: choice
|
||||
options: [auto, minor, major]
|
||||
default: auto
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: prerelease-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine prerelease version
|
||||
id: bump
|
||||
run: |
|
||||
git fetch --tags
|
||||
|
||||
# Capture the exact commit we're building so build/merge jobs are pinned to it
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Get latest stable tag (exclude prerelease tags)
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
echo "Latest stable: $STABLE"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
|
||||
NEXT_MAJOR="$((MAJOR + 1)).0.0"
|
||||
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
|
||||
TARGET="$NEXT_MAJOR"
|
||||
else
|
||||
TARGET="${MAJOR}.$((MINOR + 1)).0"
|
||||
fi
|
||||
echo "Target: $TARGET"
|
||||
|
||||
# Find the highest existing prerelease N for this target and increment
|
||||
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
|
||||
N=$(( ${LAST_N:-0} + 1 ))
|
||||
|
||||
NEW_VERSION="${TARGET}-pre.${N}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$STABLE → $NEW_VERSION"
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Prepare platform tag-safe name
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
|
||||
|
||||
- name: Push git tag
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
|
||||
- name: Clean up old prerelease tags
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
KEEP=20
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
|
||||
git fetch --tags
|
||||
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
|
||||
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
|
||||
TOTAL=${#ALL_TAGS[@]}
|
||||
DELETE_COUNT=$((TOTAL - KEEP))
|
||||
if [ "$DELETE_COUNT" -gt 0 ]; then
|
||||
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
|
||||
echo "Deleting old prerelease tag: $TAG"
|
||||
git push origin --delete "$TAG"
|
||||
done
|
||||
fi
|
||||
@@ -3,11 +3,33 @@ name: Build & Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Force bump line (auto = patch/finalize as today)'
|
||||
type: choice
|
||||
options: [auto, patch, minor, major]
|
||||
default: auto
|
||||
confirm_major:
|
||||
description: "Type MAJOR (all caps) to confirm a major release"
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: stable-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,48 +39,78 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
run: |
|
||||
# Check if this push is a merge commit from dev branch
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
|
||||
git fetch --tags
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
# Derive version from git tags — no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ]; then
|
||||
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
|
||||
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
BUMP="major"
|
||||
elif [ "$BUMP_INPUT" = "minor" ]; then
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
BUMP="minor"
|
||||
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
|
||||
BUMP="minor"
|
||||
else
|
||||
elif [ "$BUMP_INPUT" = "patch" ]; then
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
BUMP="patch"
|
||||
else
|
||||
# auto: finalize in-flight prerelease if one exists, else patch
|
||||
if [ -n "$PRE_TAG" ]; then
|
||||
PRE_BASE="${PRE_TAG#v}"
|
||||
PRE_BASE="${PRE_BASE%-pre.*}"
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump — it bypasses confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
# If prerelease base is strictly greater than stable, finalize it
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(node -p "require('./server/package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
if [ "$BUMP" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update both package.json files
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
@@ -97,6 +149,8 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
@@ -137,14 +191,29 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION=${{ needs.version-bump.outputs.version }}
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
docker buildx imagetools create \
|
||||
-t mauriceboe/trek:latest \
|
||||
-t mauriceboe/trek:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: version-bump
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Publish Helm chart
|
||||
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Enforce PR Target Branch
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// bypass-branch-check label skips all enforcement
|
||||
if (labels.includes('bypass-branch-check')) {
|
||||
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wiki-only PRs are exempt from branch enforcement
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Base is main — check if this user is a maintainer
|
||||
let permission = 'none';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.pull_request.user.login,
|
||||
});
|
||||
permission = data.permission;
|
||||
} catch (_) {
|
||||
// User is not a collaborator — treat as 'none'
|
||||
}
|
||||
|
||||
if (['admin', 'write'].includes(permission)) {
|
||||
console.log(`User has '${permission}' permission, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already labeled — avoid spamming on every push
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
core.setFailed("PR must target `dev`, not `main`.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the label exists
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
color: 'd73a4a',
|
||||
description: 'PR is targeting the wrong base branch',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['wrong-base-branch'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: [
|
||||
'## Wrong target branch',
|
||||
'',
|
||||
'This PR targets `main`, but contributions must go through `dev` first.',
|
||||
'',
|
||||
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
|
||||
'',
|
||||
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
|
||||
'',
|
||||
'> _If you need to merge directly to `main`, contact a maintainer._',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.setFailed("PR must target `dev`, not `main`.");
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Lint & Prettier
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run lint & format check
|
||||
id: checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd shared
|
||||
npm run lint
|
||||
npm run format:check
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: [
|
||||
'## ❌ Lint & Prettier check failed',
|
||||
'',
|
||||
'Please fix the issues locally by running the following commands inside the `shared` package:',
|
||||
'',
|
||||
'```bash',
|
||||
'cd shared',
|
||||
'npm run lint',
|
||||
'npm run format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
- name: Fail the job if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
run: exit 1
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
exit-code: true
|
||||
+102
-9
@@ -4,18 +4,51 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
i18n-parity:
|
||||
name: i18n Key Parity
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd shared && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -25,12 +58,33 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd server && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
@@ -39,6 +93,45 @@ jobs:
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
name: backend-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd client && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd client && npm run lint:check
|
||||
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
- name: Run tests
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Deploy Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wiki-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: clone
|
||||
+10
-2
@@ -3,6 +3,10 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
shared/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
# Generated PWA icons (built from SVG via prebuild)
|
||||
client/public/icons/*.png
|
||||
@@ -16,7 +20,7 @@ client/public/icons/*.png
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
server/data/*
|
||||
server/uploads/
|
||||
|
||||
# Environment
|
||||
@@ -57,4 +61,8 @@ coverage
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
.scannerwork
|
||||
test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
@@ -1,281 +0,0 @@
|
||||
# TREK Security & Code Quality Audit
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Auditor:** Automated comprehensive audit
|
||||
**Scope:** Full codebase — server, client, infrastructure, dependencies
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Security](#1-security)
|
||||
2. [Code Quality](#2-code-quality)
|
||||
3. [Best Practices](#3-best-practices)
|
||||
4. [Dependency Hygiene](#4-dependency-hygiene)
|
||||
5. [Documentation & DX](#5-documentation--dx)
|
||||
6. [Testing](#6-testing)
|
||||
7. [Remediation Summary](#7-remediation-summary)
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### 1.1 General
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| S-1 | **CRITICAL** | `server/src/middleware/auth.ts` | 17 | JWT `verify()` does not pin algorithm — accepts whatever algorithm is in the token header, potentially including `none`. | Pass `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls. | FIXED |
|
||||
| S-2 | **HIGH** | `server/src/websocket.ts` | 56 | Same JWT verify without algorithm pinning in WebSocket auth. | Pin algorithm to HS256. | FIXED |
|
||||
| S-3 | **HIGH** | `server/src/middleware/mfaPolicy.ts` | 54 | Same JWT verify without algorithm pinning. | Pin algorithm to HS256. | FIXED |
|
||||
| S-4 | **HIGH** | `server/src/routes/oidc.ts` | 84-88 | OIDC `generateToken()` includes excessive claims (username, email, role) in JWT payload. If the JWT is leaked, this exposes PII. | Only include `{ id: user.id }` in token, consistent with auth.ts. | FIXED |
|
||||
| S-5 | **HIGH** | `client/src/api/websocket.ts` | 27 | Auth token passed in WebSocket URL query string (`?token=`). Tokens in URLs appear in server logs, proxy logs, and browser history. | Document as known limitation; WebSocket protocol doesn't easily support headers from browsers. Add `LOW` priority note to switch to message-based auth in the future. | DOCUMENTED |
|
||||
| S-6 | **HIGH** | `client/vite.config.js` | 47-56 | Service worker caches ALL `/api/.*` responses with `NetworkFirst`, including auth tokens, user data, budget, reservations. Data persists after logout. | Exclude sensitive API paths from caching: `/api/auth/.*`, `/api/admin/.*`, `/api/backup/.*`. | FIXED |
|
||||
| S-7 | **HIGH** | `client/vite.config.js` | 57-65 | User-uploaded files (possibly passport scans, booking confirmations) cached with `CacheFirst` for 30 days, persisting after logout. | Reduce cache lifetime; add note about clearing on logout. | FIXED |
|
||||
| S-8 | **MEDIUM** | `server/src/index.ts` | 60 | CSP allows `'unsafe-inline'` for scripts, weakening XSS protection. | Remove `'unsafe-inline'` from `scriptSrc` if Vite build doesn't require it. If needed for development, only allow in non-production. | FIXED |
|
||||
| S-9 | **MEDIUM** | `server/src/index.ts` | 64 | CSP `connectSrc` allows `http:` and `https:` broadly, permitting connections to any origin. | Restrict to known API domains (nominatim, overpass, Google APIs) or use `'self'` with specific external origins. | FIXED |
|
||||
| S-10 | **MEDIUM** | `server/src/index.ts` | 62 | CSP `imgSrc` allows `http:` broadly. | Restrict to `https:` and `'self'` plus known image domains. | FIXED |
|
||||
| S-11 | **MEDIUM** | `server/src/websocket.ts` | 84-90 | No message size limit on WebSocket messages. A malicious client could send very large messages to exhaust server memory. | Set `maxPayload` on WebSocketServer configuration. | FIXED |
|
||||
| S-12 | **MEDIUM** | `server/src/websocket.ts` | 84 | No rate limiting on WebSocket messages. A client can flood the server with join/leave messages. | Add per-connection message rate limiting. | FIXED |
|
||||
| S-13 | **MEDIUM** | `server/src/websocket.ts` | 29 | No origin validation on WebSocket connections. | Add origin checking against allowed origins. | FIXED |
|
||||
| S-14 | **MEDIUM** | `server/src/routes/auth.ts` | 157-163 | JWT tokens have 24h expiry with no refresh token mechanism. Long-lived tokens increase window of exposure if leaked. | Document as accepted risk for self-hosted app. Consider refresh tokens in future. | DOCUMENTED |
|
||||
| S-15 | **MEDIUM** | `server/src/routes/auth.ts` | 367-368 | Password change does not invalidate existing JWT tokens. Old tokens remain valid for up to 24h. | Implement token version/generation tracking, or reduce token expiry and add refresh tokens. | REQUIRES MANUAL REVIEW |
|
||||
| S-16 | **MEDIUM** | `server/src/services/mfaCrypto.ts` | 2, 5 | MFA encryption key is derived from JWT_SECRET. If JWT_SECRET is compromised, all MFA secrets are also compromised. Single point of failure. | Use a separate MFA_ENCRYPTION_KEY env var, or derive using a different salt/purpose. Current implementation with `:mfa:v1` salt is acceptable but tightly coupled. | DOCUMENTED |
|
||||
| S-17 | **MEDIUM** | `server/src/routes/maps.ts` | 429 | Google API key exposed in URL query string (`&key=${apiKey}`). Could appear in logs. | Use header-based auth (X-Goog-Api-Key) consistently. Already used elsewhere in the file. | FIXED |
|
||||
| S-18 | **MEDIUM** | `MCP.md` | 232-235 | Contains publicly accessible database download link with hardcoded credentials (`admin@admin.com` / `admin123`). | Remove credentials from documentation. | FIXED |
|
||||
| S-19 | **LOW** | `server/src/index.ts` | 229 | Error handler logs full error object including stack trace to console. In containerized deployments, this could leak to centralized logging. | Sanitize error logging in production. | FIXED |
|
||||
| S-20 | **LOW** | `server/src/routes/backup.ts` | 301-304 | Error detail leaked in non-production environments (`detail: process.env.NODE_ENV !== 'production' ? msg : undefined`). | Acceptable for dev, but ensure it's consistently not leaked in production. Already correct. | OK |
|
||||
|
||||
### 1.2 Auth (JWT + OIDC + TOTP)
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| A-1 | **CRITICAL** | All jwt.verify calls | Multiple | JWT algorithm not pinned. `jsonwebtoken` library defaults to accepting the algorithm specified in the token header, which could include `none`. | Add `{ algorithms: ['HS256'] }` to every `jwt.verify()` call. | FIXED |
|
||||
| A-2 | **MEDIUM** | `server/src/routes/auth.ts` | 315-318 | MFA login token uses same JWT_SECRET and same `jwt.sign()`. Purpose field `mfa_login` prevents misuse but should use a shorter expiry. Currently 5m which is acceptable. | OK — 5 minute expiry is reasonable. | OK |
|
||||
| A-3 | **MEDIUM** | `server/src/routes/oidc.ts` | 113-143 | OIDC redirect URI is dynamically constructed from request headers (`x-forwarded-proto`, `x-forwarded-host`). An attacker who can control these headers could redirect the callback to a malicious domain. | Validate the constructed redirect URI against an allowlist, or use a configured base URL from env vars. | FIXED |
|
||||
| A-4 | **LOW** | `server/src/routes/auth.ts` | 21 | TOTP `window: 1` allows codes from adjacent time periods (±30s). This is standard and acceptable. | OK | OK |
|
||||
|
||||
### 1.3 SQLite (better-sqlite3)
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| D-1 | **HIGH** | `server/src/routes/files.ts` | 90-91 | Dynamic SQL with `IN (${placeholders})` — however, placeholders are correctly generated from array length and values are parameterized. **Not an injection risk.** | OK — pattern is safe. | OK |
|
||||
| D-2 | **MEDIUM** | `server/src/routes/auth.ts` | 455 | Dynamic SQL `UPDATE users SET ${updates.join(', ')} WHERE id = ?` — column names come from controlled server-side code, not user input. Parameters are properly bound. | OK — column names are from a controlled set. | OK |
|
||||
| D-3 | **LOW** | `server/src/db/database.ts` | 26-28 | WAL mode and busy_timeout configured. Good. | OK | OK |
|
||||
|
||||
### 1.4 WebSocket (ws)
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| W-1 | **MEDIUM** | `server/src/websocket.ts` | 29 | No `maxPayload` set on WebSocketServer. Default is 100MB which is excessive. | Set `maxPayload: 64 * 1024` (64KB). | FIXED |
|
||||
| W-2 | **MEDIUM** | `server/src/websocket.ts` | 84-110 | Only `join` and `leave` message types are handled; unknown types are silently ignored. This is acceptable but there is no schema validation on the message structure. | Add basic type/schema validation using Zod. | FIXED |
|
||||
| W-3 | **LOW** | `server/src/websocket.ts` | 88 | `JSON.parse` errors are silently caught with empty catch. | Log malformed messages at debug level. | FIXED |
|
||||
|
||||
### 1.5 Express
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| E-1 | **LOW** | `server/src/index.ts` | 82 | Body parser limit set to 100KB. Good. | OK | OK |
|
||||
| E-2 | **LOW** | `server/src/index.ts` | 14-16 | Trust proxy configured conditionally. Good. | OK | OK |
|
||||
| E-3 | **LOW** | `server/src/index.ts` | 121-136 | Path traversal protection on uploads endpoint. Uses `path.basename` and `path.resolve` check. Good. | OK | OK |
|
||||
|
||||
### 1.6 PWA / Workbox
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| P-1 | **HIGH** | `client/vite.config.js` | 47-56 | API response caching includes sensitive endpoints. | Exclude auth, admin, backup, and settings endpoints from caching. | FIXED |
|
||||
| P-2 | **MEDIUM** | `client/vite.config.js` | 23, 31, 42, 54, 63 | `cacheableResponse: { statuses: [0, 200] }` — status 0 represents opaque responses which may cache error responses silently. | Remove status 0 from API and upload caches (keep for CDN/map tiles where CORS may return opaque responses). | FIXED |
|
||||
| P-3 | **MEDIUM** | `client/src/store/authStore.ts` | 126-135 | Logout does not clear service worker caches. Sensitive data persists after logout. | Clear CacheStorage for `api-data` and `user-uploads` caches on logout. | FIXED |
|
||||
|
||||
---
|
||||
|
||||
## 2. Code Quality
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| Q-1 | **MEDIUM** | `client/src/store/authStore.ts` | 153-161 | `loadUser` silently catches all errors and logs user out. A transient network failure logs the user out. | Only logout on 401 responses, not on network errors. | FIXED |
|
||||
| Q-2 | **MEDIUM** | `client/src/hooks/useRouteCalculation.ts` | 36 | `useCallback` depends on entire `tripStore` object, defeating memoization. | Select only needed properties from the store. | DOCUMENTED |
|
||||
| Q-3 | **MEDIUM** | `client/src/hooks/useTripWebSocket.ts` | 14 | `collabFileSync` captures stale `tripStore` reference from initial render. | Use `useTripStore.getState()` instead. | DOCUMENTED |
|
||||
| Q-4 | **MEDIUM** | `client/src/store/authStore.ts` | 38 vs 105 | `register` function accepts 4 params but TypeScript interface only declares 3. | Update interface to include optional `invite_token`. | FIXED |
|
||||
| Q-5 | **LOW** | `client/src/store/slices/filesSlice.ts` | — | Empty catch block on file link operation (`catch {}`). | Log error. | DOCUMENTED |
|
||||
| Q-6 | **LOW** | `client/src/App.tsx` | 101, 108 | Empty catch blocks silently swallow errors. | Add minimal error logging. | DOCUMENTED |
|
||||
| Q-7 | **LOW** | `client/src/App.tsx` | 155 | `RegisterPage` imported but never used — `/register` route renders `LoginPage`. | Remove unused import. | FIXED |
|
||||
| Q-8 | **LOW** | `client/tsconfig.json` | 14 | `strict: false` disables TypeScript strict mode. | Enable strict mode and fix resulting type errors. | REQUIRES MANUAL REVIEW |
|
||||
| Q-9 | **LOW** | `client/src/main.tsx` | 7 | Non-null assertion on `getElementById('root')!`. | Add null check. | DOCUMENTED |
|
||||
| Q-10 | **LOW** | `server/src/routes/files.ts` | 278 | Empty catch block on file link insert (`catch {}`). | Log duplicate link errors. | FIXED |
|
||||
| Q-11 | **LOW** | `server/src/db/database.ts` | 20-21 | Silent catch on WAL checkpoint in `initDb`. | Log warning on failure. | DOCUMENTED |
|
||||
|
||||
---
|
||||
|
||||
## 3. Best Practices
|
||||
|
||||
### 3.1 Node / Express
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| B-1 | **LOW** | `server/src/index.ts` | 251-271 | Graceful shutdown implemented with SIGTERM/SIGINT handlers. Good — closes DB, HTTP server, with 10s timeout. | OK | OK |
|
||||
| B-2 | **LOW** | `server/src/index.ts` | 87-112 | Debug logging redacts sensitive fields. Good. | OK | OK |
|
||||
|
||||
### 3.2 React / Vite
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| V-1 | **MEDIUM** | `client/vite.config.js` | — | No explicit `build.sourcemap: false` for production. Source maps may be generated. | Add `build: { sourcemap: false }` to Vite config. | FIXED |
|
||||
| V-2 | **LOW** | `client/index.html` | 24 | Leaflet CSS loaded from unpkg CDN without Subresource Integrity (SRI) hash. | Add `integrity` and `crossorigin` attributes. | FIXED |
|
||||
|
||||
### 3.3 Docker
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| K-1 | **MEDIUM** | `Dockerfile` | 2, 10 | Base images use floating tags (`node:22-alpine`), not pinned to digest. | Pin to specific digest for reproducible builds. | DOCUMENTED |
|
||||
| K-2 | **MEDIUM** | `Dockerfile` | — | No `HEALTHCHECK` instruction. Only docker-compose has health check. | Add `HEALTHCHECK` to Dockerfile for standalone deployments. | FIXED |
|
||||
| K-3 | **LOW** | `.dockerignore` | — | Missing exclusions for `chart/`, `docs/`, `.github/`, `docker-compose.yml`, `*.sqlite*`. | Add missing exclusions. | FIXED |
|
||||
|
||||
### 3.4 docker-compose.yml
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| C-1 | **HIGH** | `docker-compose.yml` | 25 | `JWT_SECRET` defaults to empty string if not set. App auto-generates one, but it changes on restart, invalidating all sessions. | Log a prominent warning on startup if JWT_SECRET is auto-generated. | FIXED |
|
||||
| C-2 | **MEDIUM** | `docker-compose.yml` | — | No resource limits defined for the `app` service. | Add `deploy.resources.limits` section. | DOCUMENTED |
|
||||
|
||||
### 3.5 Git Hygiene
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| G-1 | **HIGH** | `.gitignore` | 12-14 | Missing `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` patterns. Only `*.db` variants covered. | Add sqlite patterns. | FIXED |
|
||||
| G-2 | **LOW** | — | — | No `.env` or `.sqlite` files found in git history. | OK | OK |
|
||||
|
||||
### 3.6 Helm Chart
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| H-1 | **MEDIUM** | `chart/templates/secret.yaml` | 22 | `randAlphaNum 32` generates a new JWT secret on every `helm upgrade`, invalidating all sessions. | Use `lookup` to preserve existing secret across upgrades. | FIXED |
|
||||
| H-2 | **MEDIUM** | `chart/values.yaml` | 3 | Default image tag is `latest`. | Use a specific version tag. | DOCUMENTED |
|
||||
| H-3 | **MEDIUM** | `chart/templates/deployment.yaml` | — | No `securityContext` on pod or container. Runs as root by default. | Add `runAsNonRoot: true`, `runAsUser: 1000`. | FIXED |
|
||||
| H-4 | **MEDIUM** | `chart/templates/pvc.yaml` | — | PVC always created regardless of `.Values.persistence.enabled`. | Add conditional check. | FIXED |
|
||||
| H-5 | **LOW** | `chart/values.yaml` | 41 | `resources: {}` — no default resource requests or limits. | Add sensible defaults. | FIXED |
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency Hygiene
|
||||
|
||||
### 4.1 npm audit
|
||||
|
||||
| Package | Severity | Description | Status |
|
||||
|---------|----------|-------------|--------|
|
||||
| `serialize-javascript` (via vite-plugin-pwa → workbox-build → @rollup/plugin-terser) | **HIGH** | RCE via RegExp.flags / CPU exhaustion DoS | Fix requires `vite-plugin-pwa` major version upgrade. | DOCUMENTED |
|
||||
| `picomatch` (via @rollup/pluginutils, tinyglobby) | **MODERATE** | ReDoS via extglob quantifiers | `npm audit fix` available. | FIXED |
|
||||
|
||||
**Server:** 0 vulnerabilities.
|
||||
|
||||
### 4.2 Outdated Dependencies (Notable)
|
||||
|
||||
| Package | Current | Latest | Risk | Status |
|
||||
|---------|---------|--------|------|--------|
|
||||
| `express` | ^4.18.3 | 5.2.1 | Major version — breaking changes | DOCUMENTED |
|
||||
| `uuid` | ^9.0.0 | 13.0.0 | Major version | DOCUMENTED |
|
||||
| `dotenv` | ^16.4.1 | 17.3.1 | Major version | DOCUMENTED |
|
||||
| `lucide-react` | ^0.344.0 | 1.7.0 | Major version | DOCUMENTED |
|
||||
| `react` | ^18.2.0 | 19.2.4 | Major version | DOCUMENTED |
|
||||
| `zustand` | ^4.5.2 | 5.0.12 | Major version | DOCUMENTED |
|
||||
|
||||
> Major version upgrades require manual evaluation and testing. Not applied in this remediation pass.
|
||||
|
||||
---
|
||||
|
||||
## 5. Documentation & DX
|
||||
|
||||
| # | Severity | File | Description | Recommended Fix | Status |
|
||||
|---|----------|------|-------------|-----------------|--------|
|
||||
| X-1 | **MEDIUM** | `server/.env.example` | Missing many env vars documented in README: `OIDC_*`, `FORCE_HTTPS`, `TRUST_PROXY`, `DEMO_MODE`, `TZ`, `ALLOWED_ORIGINS`, `DEBUG`. | Add all configurable env vars. | FIXED |
|
||||
| X-2 | **MEDIUM** | `server/.env.example` | JWT_SECRET placeholder is `your-super-secret-jwt-key-change-in-production` — easily overlooked. | Use `CHANGEME_GENERATE_WITH_openssl_rand_hex_32`. | FIXED |
|
||||
| X-3 | **LOW** | `server/.env.example` | `PORT=3001` differs from Docker default of `3000`. | Align to `3000`. | FIXED |
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
| # | Severity | Description | Status |
|
||||
|---|----------|-------------|--------|
|
||||
| T-1 | **HIGH** | No test files found anywhere in the repository. Zero test coverage for auth flows, WebSocket handling, SQLite queries, API routes, or React components. | REQUIRES MANUAL REVIEW |
|
||||
| T-2 | **HIGH** | No test framework configured (no jest, vitest, or similar in dependencies). | REQUIRES MANUAL REVIEW |
|
||||
| T-3 | **MEDIUM** | No CI step runs tests before building Docker image. | DOCUMENTED |
|
||||
|
||||
---
|
||||
|
||||
## 7. Remediation Summary
|
||||
|
||||
### Applied Fixes
|
||||
|
||||
- **Immich SSRF prevention** — Added URL validation on save (block private IPs, metadata endpoints, non-HTTP protocols)
|
||||
- **Immich API key isolation** — Removed `userId` query parameter from asset proxy endpoints; all Immich requests now use authenticated user's own credentials only
|
||||
- **Immich asset ID validation** — Added alphanumeric pattern validation to prevent path traversal in proxied URLs
|
||||
- **JWT algorithm pinning** — Added `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls (auth middleware, MFA policy, WebSocket, OIDC, auth routes)
|
||||
- **OIDC token payload** — Reduced to `{ id }` only, matching auth.ts pattern
|
||||
- **OIDC redirect URI validation** — Validates against `APP_URL` env var when set
|
||||
- **WebSocket hardening** — Added `maxPayload: 64KB`, message rate limiting (30 msg/10s), origin validation, improved message validation
|
||||
- **CSP tightening** — Removed `'unsafe-inline'` from scripts in production, restricted `connectSrc` and `imgSrc` to known domains
|
||||
- **PWA cache security** — Excluded sensitive API paths from caching, removed opaque response caching for API/uploads, clear caches on logout
|
||||
- **Service worker cache cleanup on logout**
|
||||
- **Google API key** — Moved from URL query string to header in maps photo endpoint
|
||||
- **MCP.md credentials** — Removed hardcoded demo credentials
|
||||
- **.gitignore** — Added `*.sqlite*` patterns
|
||||
- **.dockerignore** — Added missing exclusions
|
||||
- **Dockerfile** — Added HEALTHCHECK instruction
|
||||
- **Helm chart** — Fixed secret rotation, added securityContext, conditional PVC, resource defaults
|
||||
- **Vite config** — Disabled source maps in production
|
||||
- **CDN integrity** — Added SRI hash for Leaflet CSS
|
||||
- **.env.example** — Complete with all env vars
|
||||
- **Various code quality fixes** — Removed dead imports, fixed empty catch blocks, fixed auth store interface
|
||||
|
||||
### Requires Manual Review
|
||||
|
||||
- Password change should invalidate existing tokens (S-15)
|
||||
- TypeScript strict mode should be enabled (Q-8)
|
||||
- Test suite needs to be created from scratch (T-1, T-2)
|
||||
- Major dependency upgrades (express 5, React 19, zustand 5, etc.)
|
||||
- `serialize-javascript` vulnerability fix requires vite-plugin-pwa major upgrade
|
||||
|
||||
### 1.7 Immich Integration
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| I-1 | **CRITICAL** | `server/src/routes/immich.ts` | 38-39, 85, 199, 250, 274 | SSRF via user-controlled `immich_url`. Users can set any URL which is then used in `fetch()` calls, allowing requests to internal metadata endpoints (169.254.169.254), localhost services, etc. | Validate URL on save: require HTTP(S) protocol, block private/internal IPs. | FIXED |
|
||||
| I-2 | **CRITICAL** | `server/src/routes/immich.ts` | 194-196, 244-246, 269-270 | Asset info/thumbnail/original endpoints accept `userId` query param, allowing any authenticated user to proxy requests through another user's Immich API key. This exposes other users' Immich credentials and photo libraries. | Restrict all Immich proxy endpoints to the authenticated user's own credentials only. | FIXED |
|
||||
| I-3 | **MEDIUM** | `server/src/routes/immich.ts` | 199, 250, 274 | `assetId` URL parameter used directly in `fetch()` URL construction. Path traversal characters could redirect requests to unintended Immich API endpoints. | Validate assetId matches `[a-zA-Z0-9_-]+` pattern. | FIXED |
|
||||
|
||||
### 1.8 Admin Routes
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| AD-1 | **MEDIUM** | `server/src/routes/admin.ts` | 302-310 | Self-update endpoint runs `git pull` then `npm run build`. While admin-only and `npm install` uses `--ignore-scripts`, `npm run build` executes whatever is in the pulled package.json. A compromised upstream could execute arbitrary code. | Document as accepted risk for self-hosted self-update feature. Users should pin to specific versions. | DOCUMENTED |
|
||||
|
||||
### 1.9 Client-Side XSS
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| X-1 | **CRITICAL** | `client/src/components/Admin/GitHubPanel.tsx` | 66, 106 | `dangerouslySetInnerHTML` with `inlineFormat()` renders GitHub release notes as HTML without escaping. Malicious HTML in release notes could execute scripts. | Escape HTML entities before applying markdown-style formatting. Validate link URLs. | FIXED |
|
||||
| X-2 | **LOW** | `client/src/components/Map/MapView.tsx` | divIcon | Uses `escAttr()` for HTML sanitization in divIcon strings. Properly mitigated. | OK | OK |
|
||||
|
||||
### 1.10 Route Calculator Bug
|
||||
|
||||
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|
||||
|---|----------|------|---------|-------------|-----------------|--------|
|
||||
| RC-1 | **HIGH** | `client/src/components/Map/RouteCalculator.ts` | 16 | OSRM URL hardcodes `'driving'` profile, ignoring the `profile` parameter. Walking/cycling routes always return driving results. | Use the `profile` parameter in URL construction. | FIXED |
|
||||
|
||||
### Additional Findings (from exhaustive scan)
|
||||
|
||||
- **MEDIUM** — `server/src/index.ts:121-136`: Upload files (`/uploads/:type/:filename`) served without authentication. UUIDs are unguessable but this is security-through-obscurity. **REQUIRES MANUAL REVIEW** — adding auth would break shared trip image URLs.
|
||||
- **MEDIUM** — `server/src/routes/oidc.ts:194`: OIDC token exchange error was logging full token response (potentially including access tokens). **FIXED** — now logs only HTTP status.
|
||||
- **MEDIUM** — `server/src/services/notifications.ts:194-196`: Email body is not HTML-escaped. User-generated content (trip names, usernames) interpolated directly into HTML email template. Potential stored XSS in email clients. **DOCUMENTED** — needs HTML entity escaping.
|
||||
- **LOW** — `server/src/demo/demo-seed.ts:7-9`: Hardcoded demo credentials (`demo12345`, `admin12345`). Intentional for demo mode but dangerous if DEMO_MODE accidentally left on in production. Already has startup warning.
|
||||
- **LOW** — `server/src/routes/auth.ts:742`: MFA setup returns plaintext TOTP secret to client. This is standard TOTP enrollment flow — users need the secret for manual entry. Must be served over HTTPS.
|
||||
- **LOW** — `server/src/routes/auth.ts:473`: Admin settings GET returns API keys in full (not masked). Only accessible to admins.
|
||||
- **LOW** — `server/src/routes/auth.ts:564`: SMTP password stored as plaintext in `app_settings` table. Masked in API response but unencrypted at rest.
|
||||
|
||||
### Accepted Risks (Documented)
|
||||
|
||||
- WebSocket token in URL query string (browser limitation)
|
||||
- 24h JWT expiry without refresh tokens (acceptable for self-hosted)
|
||||
- MFA encryption key derived from JWT_SECRET (noted coupling)
|
||||
- localStorage for token storage (standard SPA pattern)
|
||||
- Upload files served without auth (UUID-based obscurity, needed for shared trips)
|
||||
+6
-17
@@ -4,11 +4,13 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
|
||||
## Ground Rules
|
||||
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -35,22 +37,9 @@ fix(maps): correct zoom level on Safari
|
||||
feat(budget): add CSV export for expenses
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
|
||||
# Server
|
||||
cd server && npm install && npm run dev
|
||||
|
||||
# Client (separate terminal)
|
||||
cd client && npm install && npm run dev
|
||||
```
|
||||
|
||||
Server: `http://localhost:3001` | Client: `http://localhost:5173`
|
||||
|
||||
On first run, check the server logs for the auto-generated admin credentials.
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
|
||||
+81
-19
@@ -1,32 +1,93 @@
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
RUN npm ci --workspace=shared
|
||||
COPY shared/ ./shared/
|
||||
RUN npm run build --workspace=shared
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||
FROM node:24-trixie-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
||||
# kitinerary-extractor for booking-confirmation import:
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
fi && \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary).
|
||||
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
ln -s /app/uploads /app/server/uploads && \
|
||||
ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -34,4 +95,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -9,9 +9,15 @@ structured API.
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
|
||||
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
|
||||
- [Authentication](#authentication)
|
||||
- [OAuth Scopes](#oauth-scopes)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Compound Tools](#compound-tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
@@ -21,22 +27,52 @@ structured API.
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
### 2. Connect your MCP client
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
#### Option A: OAuth 2.1 (recommended)
|
||||
|
||||
1. Click **Create New Token**
|
||||
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
|
||||
management required — just provide the server URL:
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure your MCP client
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
|
||||
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
|
||||
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
|
||||
(`trekcs_` prefix) and fixed scopes that you define up front.
|
||||
|
||||
#### Option B: Static API Token (deprecated)
|
||||
|
||||
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
|
||||
|
||||
1. Go to **Settings > Integrations > MCP** and create an API token.
|
||||
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
|
||||
3. Add it to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -54,7 +90,69 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
|
||||
static token will receive deprecation warnings in the AI client via server instructions and tool results.
|
||||
|
||||
Each user can create up to **10 static tokens**.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
|
||||
|
||||
| Method | Token prefix | Access level | TTL | Notes |
|
||||
|--------|-------------|-------------|-----|-------|
|
||||
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
|
||||
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
|
||||
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
|
||||
|
||||
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
|
||||
|
||||
---
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
|
||||
that match your granted scopes for that session.
|
||||
|
||||
| Scope | Permission | Group |
|
||||
|-------|-----------|-------|
|
||||
| `trips:read` | View trips & itineraries | Trips |
|
||||
| `trips:write` | Edit trips & itineraries | Trips |
|
||||
| `trips:delete` | Delete trips (irreversible) | Trips |
|
||||
| `trips:share` | Manage share links | Trips |
|
||||
| `places:read` | View places & map data | Places |
|
||||
| `places:write` | Manage places | Places |
|
||||
| `atlas:read` | View Atlas | Atlas |
|
||||
| `atlas:write` | Manage Atlas | Atlas |
|
||||
| `packing:read` | View packing lists | Packing |
|
||||
| `packing:write` | Manage packing lists | Packing |
|
||||
| `todos:read` | View to-do lists | To-dos |
|
||||
| `todos:write` | Manage to-do lists | To-dos |
|
||||
| `budget:read` | View budget | Budget |
|
||||
| `budget:write` | Manage budget | Budget |
|
||||
| `reservations:read` | View reservations | Reservations |
|
||||
| `reservations:write` | Manage reservations | Reservations |
|
||||
| `collab:read` | View collaboration | Collaboration |
|
||||
| `collab:write` | Manage collaboration | Collaboration |
|
||||
| `notifications:read` | View notifications | Notifications |
|
||||
| `notifications:write` | Manage notifications | Notifications |
|
||||
| `vacay:read` | View vacation plans | Vacation |
|
||||
| `vacay:write` | Manage vacation plans | Vacation |
|
||||
| `geo:read` | Maps & geocoding | Geo |
|
||||
| `weather:read` | Weather forecasts | Weather |
|
||||
| `journey:read` | View journeys | Journey |
|
||||
| `journey:write` | Manage journeys | Journey |
|
||||
| `journey:share` | Manage journey share links | Journey |
|
||||
|
||||
**Scope rules:**
|
||||
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,11 +165,15 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 API tokens per user. |
|
||||
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
|
||||
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
|
||||
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
|
||||
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
|
||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||
|
||||
---
|
||||
|
||||
@@ -80,118 +182,359 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
| Resource | URI | Description |
|
||||
|-------------------|--------------------------------------------|-----------------------------------------------------------|
|
||||
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||
### Core Resources
|
||||
|
||||
| Resource | URI | Description |
|
||||
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
|
||||
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
|
||||
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
|
||||
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
|
||||
|
||||
### Addon-Gated Resources
|
||||
|
||||
These resources are only available when the corresponding addon is enabled by an admin.
|
||||
|
||||
| Resource | URI | Addon | Description |
|
||||
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
|
||||
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
|
||||
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
|
||||
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
|
||||
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
|
||||
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||
|
||||
---
|
||||
|
||||
## Tools (read-write)
|
||||
|
||||
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
|
||||
about a trip in a single call.
|
||||
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
|
||||
trip in a single call.
|
||||
|
||||
### Trip Summary
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||
|
||||
### Compound Tools
|
||||
|
||||
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||
|
||||
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||
|
||||
| Tool | Wraps | Description |
|
||||
|---|---|---|
|
||||
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||
|
||||
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||
|
||||
---
|
||||
|
||||
### Trips
|
||||
|
||||
| Tool | Description |
|
||||
|---------------|---------------------------------------------------------------------------------------------|
|
||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||
| Tool | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------------------|
|
||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||
| `list_trip_members` | List the owner and all collaborators of a trip. |
|
||||
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
|
||||
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
|
||||
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
|
||||
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
|
||||
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
|
||||
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
|
||||
| `delete_share_link` | Revoke the public share link for a trip. |
|
||||
|
||||
### Places
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|-----------------------------------------------------------------------------------|
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
|
||||
| `update_place` | Update any field of an existing place. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|-------------------------------------------------------------------------------|
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||
| `unassign_place` | Remove a place assignment from a day. |
|
||||
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). |
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
| Tool | Description |
|
||||
|-----------------------------|--------------------------------------------------------------------------------------|
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
| `create_day` | Add a new day to a trip with optional date and notes. |
|
||||
| `delete_day` | Delete a day from a trip. |
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||
| `unassign_place` | Remove a place assignment from a day. |
|
||||
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
|
||||
| `move_assignment` | Move a place assignment to a different day. |
|
||||
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
|
||||
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
|
||||
|
||||
### Accommodations
|
||||
|
||||
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|------------------------------------------------------------------------------------------|
|
||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||
|
||||
### Transport
|
||||
|
||||
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||
| `delete_transport` | Delete a transport booking from a trip. |
|
||||
|
||||
### Reservations
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|--------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||
| `delete_budget_item` | Remove a budget item. |
|
||||
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||
| `delete_budget_item` | Remove a budget item. |
|
||||
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
|
||||
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
|
||||
|
||||
### Packing
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|--------------------------------------------------------------|
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item or change its category. |
|
||||
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||
| `delete_packing_item` | Remove a packing item. |
|
||||
| Tool | Description |
|
||||
|-------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item or change its category. |
|
||||
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||
| `delete_packing_item` | Remove a packing item. |
|
||||
| `reorder_packing_items` | Set the display order of packing items within a trip. |
|
||||
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
|
||||
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
|
||||
| `save_packing_template` | Save the current packing list as a reusable template. |
|
||||
| `list_packing_bags` | List all packing bags for a trip. |
|
||||
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
|
||||
| `update_packing_bag` | Rename or recolor a packing bag. |
|
||||
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
|
||||
| `set_bag_members` | Assign trip members to a packing bag. |
|
||||
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
|
||||
| `set_packing_category_assignees` | Assign trip members to a packing category. |
|
||||
|
||||
### Day Notes
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-----------------------------------------------------------------------|
|
||||
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||
| `delete_day_note` | Remove a note from a day. |
|
||||
| Tool | Description |
|
||||
|-------------------|------------------------------------------------------------------------|
|
||||
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||
| `delete_day_note` | Remove a note from a day. |
|
||||
|
||||
### Collab Notes
|
||||
### To-Dos
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------------|
|
||||
| `list_todos` | List all to-do items for a trip, ordered by position. |
|
||||
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
|
||||
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
|
||||
| `toggle_todo` | Mark a to-do item as done or undone. |
|
||||
| `delete_todo` | Delete a to-do item. |
|
||||
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
|
||||
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
|
||||
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
|
||||
|
||||
### Tags
|
||||
|
||||
| Tool | Description |
|
||||
|--------------|--------------------------------------------------------------------------|
|
||||
| `list_tags` | List all tags belonging to the current user. |
|
||||
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
|
||||
| `update_tag` | Update the name or color of an existing tag. |
|
||||
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------------|------------------------------------------------------|
|
||||
| `list_notifications` | List in-app notifications with pagination and unread filter. |
|
||||
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
|
||||
| `mark_notification_read` | Mark a single notification as read. |
|
||||
| `mark_notification_unread` | Mark a single notification as unread. |
|
||||
| `mark_all_notifications_read` | Mark all notifications as read. |
|
||||
|
||||
### Maps & Weather
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|-----------------------------------------------------------------------------------------------------|
|
||||
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
|
||||
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
|
||||
| `reverse_geocode` | Get a human-readable address for given coordinates. |
|
||||
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
|
||||
| `get_weather` | Get weather forecast for a location and date. |
|
||||
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||
|
||||
### Airports
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||
|
||||
### Collab Notes _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||
| `delete_collab_note` | Delete a collab note and its associated files. |
|
||||
| `delete_collab_note` | Delete a collab note. |
|
||||
|
||||
### Bucket List
|
||||
### Collab Polls & Chat _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|------------------------------------------------------------------------------------------|
|
||||
| `list_collab_polls` | List all polls for a trip. |
|
||||
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
|
||||
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
|
||||
| `close_collab_poll` | Close a poll so no more votes can be cast. |
|
||||
| `delete_collab_poll` | Delete a poll and all its votes. |
|
||||
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
|
||||
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
|
||||
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||
|
||||
### Bucket List _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas
|
||||
### Atlas _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------------------------------------------|
|
||||
| Tool | Description |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
|
||||
### Atlas Extended _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------|
|
||||
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
|
||||
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
|
||||
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
|
||||
| `unmark_region_visited` | Remove a region from the visited list. |
|
||||
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
|
||||
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
|
||||
|
||||
### Vacay _(Vacay addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
|
||||
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
|
||||
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
|
||||
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
|
||||
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
|
||||
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
|
||||
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
|
||||
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
|
||||
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
|
||||
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
|
||||
| `add_vacay_year` | Add a calendar year to the vacation plan. |
|
||||
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
|
||||
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
|
||||
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
|
||||
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
|
||||
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
|
||||
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
|
||||
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
|
||||
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
|
||||
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
|
||||
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||
| `list_holidays` | List public holidays for a country and year. |
|
||||
|
||||
### Journey _(Journey addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||
| `delete_journey` | Delete a journey. |
|
||||
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||
|
||||
---
|
||||
|
||||
## Prompts
|
||||
|
||||
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
|
||||
|
||||
| Prompt | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------|
|
||||
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
|
||||
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
|
||||
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
|
||||
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
|
||||
|
||||
---
|
||||
|
||||
@@ -231,4 +574,4 @@ of everything that was added.
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Third-party data & attributions
|
||||
|
||||
TREK bundles and uses third-party data that requires attribution.
|
||||
|
||||
## geoBoundaries — country & sub-national boundaries
|
||||
|
||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
||||
|
||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
||||
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
||||
> https://doi.org/10.1371/journal.pone.0231866
|
||||
|
||||
geoBoundaries is licensed under **CC BY 4.0**
|
||||
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
||||
|
||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||
(gbOpen) release.
|
||||
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
||||
https://www.openstreetmap.org/copyright
|
||||
|
||||
## OurAirports — airport reference data
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
@@ -1,102 +1,174 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
||||
</picture>
|
||||
<br />
|
||||
<em>Your Trips. Your Plan.</em>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
||||
</p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||
<br />
|
||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
||||
</p>
|
||||
<br />
|
||||
|
||||

|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary>More Screenshots</summary>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Trip planning
|
||||
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile & PWA
|
||||
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
<br />
|
||||
|
||||
### Trip Planning
|
||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
||||
|
||||
### Mobile & PWA
|
||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
||||
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
||||
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
||||
|
||||
### Collaboration
|
||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
|
||||
### Addons (modular, admin-toggleable)
|
||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||
|
||||
### Customization & Admin
|
||||
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
|
||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||
- **PWA**: vite-plugin-pwa + Workbox
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Quick Start
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
@@ -104,19 +176,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
### Install as App (PWA)
|
||||
<div align="center">
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed:
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
1. Open your TREK instance in the browser (HTTPS required)
|
||||
2. **iOS**: Share button → "Add to Home Screen"
|
||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||
4. TREK launches fullscreen with its own icon, just like a native app
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Tech stack
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -139,28 +232,19 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile groups # Space-separated OIDC scopes to request (must include scopes for any claim used by OIDC_ADMIN_CLAIM)
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||
# - TRUST_PROXY=1
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME=SSO
|
||||
# - OIDC_ADMIN_CLAIM=groups
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -173,21 +257,49 @@ services:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
### Updating
|
||||
<br />
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed.
|
||||
|
||||
1. Open TREK in the browser (HTTPS required)
|
||||
2. **iOS**: Share ▸ *Add to Home Screen*
|
||||
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
|
||||
|
||||
TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
<br />
|
||||
|
||||
## Updating
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
@@ -195,27 +307,23 @@ docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
|
||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
@@ -231,8 +339,20 @@ server {
|
||||
listen 443 ssl http2;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
ssl_certificate /etc/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||
|
||||
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||
client_max_body_size 500m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -240,19 +360,8 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -261,17 +370,24 @@ server {
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
```caddy
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
<br />
|
||||
|
||||
## Environment variables
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
@@ -281,56 +397,54 @@ trek.yourdomain.com {
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. Must include scopes for any claim used by `OIDC_ADMIN_CLAIM` (e.g. add `groups` for group-based admin mapping) | `openid email profile groups` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial Setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
## Optional API Keys
|
||||
</details>
|
||||
|
||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||
|
||||
### Google Maps (Place Search & Photos)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project and enable the **Places API (New)**
|
||||
3. Create an API key under Credentials
|
||||
4. In TREK: Admin Panel → Settings → Google Maps
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
docker build -t trek .
|
||||
```
|
||||
<br />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue
|
||||
2. Email: **mauriceboe@icloud.com**
|
||||
2. Email: **report@liketrek.com**
|
||||
3. Include a description of the vulnerability and steps to reproduce
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Trademark Policy
|
||||
|
||||
## Introduction
|
||||
|
||||
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||
license does not include a license to use our trademarks.
|
||||
|
||||
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||
indicators of the software we release; and 2) our community members' desire to
|
||||
be full participants in the TREK project.
|
||||
|
||||
## Our trademarks
|
||||
|
||||
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||
goodwill, or designs (our "Marks").
|
||||
|
||||
## In general
|
||||
|
||||
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||
anyone about exactly who is the source of the software. For example, you cannot
|
||||
say you are distributing TREK when you're distributing a modified version of it,
|
||||
because people would think they would be getting the same software that they
|
||||
can get directly from us when they aren't. You also cannot use our Marks on
|
||||
your website in a way that suggests that your website is an official TREK
|
||||
website or that we endorse your website. But, if true, you can say you like
|
||||
TREK, that you participate in the TREK community, that you are providing an
|
||||
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||
TREK.
|
||||
|
||||
This fundamental requirement, that it is always clear to people what they are
|
||||
getting and from whom, is reflected throughout this policy. It should also
|
||||
serve as your guide if you are not sure about how you are using the Marks.
|
||||
|
||||
In addition:
|
||||
|
||||
* You may not use or register, in whole or in part, the Marks as part of your
|
||||
own trademark, service mark, domain name, company name, trade name, product
|
||||
name or service name.
|
||||
* Trademark law does not allow your use of names or trademarks that are too
|
||||
similar to ours. You therefore may not use an obvious variation of any of our
|
||||
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||
abbreviation for a similar or compatible product or service.
|
||||
* You agree that you will not acquire any rights in the Marks and that any
|
||||
goodwill generated by your use of the Marks and participation in our
|
||||
community inures solely to our benefit.
|
||||
|
||||
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||
|
||||
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||
the software to identify your redistribution. This kind of use only applies if
|
||||
you are redistributing an official TREK distribution that has not been changed
|
||||
in any way.
|
||||
|
||||
## Distribution of executable code that you have compiled, or modified code
|
||||
|
||||
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||
describe the origin of the software that you are providing, that is, that the
|
||||
code you are distributing is a modification of TREK. You may say, for example,
|
||||
that "this software is derived from the source code for TREK."
|
||||
|
||||
Of course, you can place your own trademarks or logos on versions of the
|
||||
software to which you have made substantive modifications, because by modifying
|
||||
the software, you have become the origin of that exact version. In that case,
|
||||
you should not use our Marks.
|
||||
|
||||
However, you may use our Marks for the distribution of code (source or
|
||||
executable) on the condition that any executable is built from an official TREK
|
||||
source code release and that any modifications are limited to switching on or
|
||||
off features already included in the software, translations into other
|
||||
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||
further modification is not permitted.
|
||||
|
||||
## Mobile wrappers, hosted instances, and forks
|
||||
|
||||
The following clarifications apply specifically to common ways TREK is
|
||||
redistributed:
|
||||
|
||||
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||
Official," etc.).
|
||||
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||
unless your wrapper distributes only an unmodified, official TREK instance
|
||||
and you have obtained permission.
|
||||
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||
of TREK," but the project name itself must be your own.
|
||||
|
||||
## Statements about your software's relation to TREK
|
||||
|
||||
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||
relationship between your software and ours. The word mark "TREK" should be
|
||||
used after a verb or preposition that describes the relationship between your
|
||||
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||
|
||||
* [Your software] uses TREK
|
||||
* [Your software] is powered by TREK
|
||||
* [Your software] runs on TREK
|
||||
* [Your software] for use with TREK
|
||||
* [Your software] for TREK
|
||||
|
||||
## Questions and permission requests
|
||||
|
||||
If you are not sure whether your intended use of the Marks is permitted under
|
||||
this policy, or if you would like to request explicit permission for a use that
|
||||
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||
the maintainers directly.
|
||||
|
||||
---
|
||||
|
||||
These guidelines are based on the
|
||||
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||
under a
|
||||
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Or install directly from the local chart:
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set ingress.enabled=true \
|
||||
@@ -32,5 +44,7 @@ See `values.yaml` for more options.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
appVersion: "3.0.22"
|
||||
@@ -22,6 +22,9 @@ data:
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
@@ -19,17 +19,23 @@ env:
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# DEFAULT_LANGUAGE: "en"
|
||||
# Default language on the login page for users with no saved preference.
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
@@ -40,7 +46,9 @@ env:
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# Set to "true" to force SSO-only mode: disables password login and password registration.
|
||||
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
|
||||
# First SSO login becomes admin on a fresh instance.
|
||||
# OIDC_ADMIN_CLAIM: ""
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
@@ -51,8 +59,10 @@ env:
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
# MCP_RATE_LIMIT: "300"
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Playwright E2E (FE7)
|
||||
e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"jsxSingleQuote": false,
|
||||
"bracketSameLine": false,
|
||||
"endOfLine": "lf",
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-imports",
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"importOrder": [
|
||||
"^[a-zA-Z]",
|
||||
"^@/.*"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderParserPlugins": [
|
||||
"typescript",
|
||||
"decorators-legacy"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
|
||||
// Relative to the config dir (client/), matching `storageState` in
|
||||
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||
const stateFile = 'e2e/.tmp/state.json'
|
||||
|
||||
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
|
||||
// seeded admin is created with must_change_password=1, so the first login goes
|
||||
// through the forced change-password step before reaching the dashboard.
|
||||
const EMAIL = 'e2e@trek.local'
|
||||
const SEED_PW = 'E2eTest12345!'
|
||||
const NEW_PW = 'E2eChanged12345!'
|
||||
|
||||
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.locator('input[type="email"]').fill(EMAIL)
|
||||
await page.locator('input[type="password"]').fill(SEED_PW)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
// must_change_password=1 → the change-password step renders two password
|
||||
// fields (new + confirm). Selector-agnostic of the UI language.
|
||||
const pw = page.locator('input[type="password"]')
|
||||
await expect(pw).toHaveCount(2)
|
||||
await pw.nth(0).fill(NEW_PW)
|
||||
await pw.nth(1).fill(NEW_PW)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||
|
||||
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||
// Dismissal is recorded server-side against this user, so clearing it here
|
||||
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||
await ok.click()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: stateFile })
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
|
||||
// re-render — against the real backend + isolated test DB.
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||
// button (the primary action lives in the footer), so click it explicitly
|
||||
// rather than pressing Enter. The Create button is the slate primary button;
|
||||
// Cancel is the bordered one.
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Authenticated smoke: the stored session lands on the dashboard and the
|
||||
// app chrome (navbar) renders instead of bouncing back to /login.
|
||||
test('authenticated session reaches the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page).toHaveURL(/\/dashboard/)
|
||||
// The shared Navbar shows the TREK brand once authenticated.
|
||||
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Infra smoke + first unauthenticated flow: the app boots, the backend is
|
||||
// reachable through the Vite proxy, and the login screen renders its form.
|
||||
test('login screen renders with a password field', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.locator('input[type="password"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
|
||||
// SQLite database. The DB file is deleted first so every run starts clean, then
|
||||
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
|
||||
//
|
||||
// The server is built once and launched as a SINGLE node process (not the
|
||||
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
|
||||
// that survive Playwright's teardown and then linger on :3001 with stale DB
|
||||
// state). A single child is killed cleanly when Playwright tears the run down.
|
||||
import { rmSync } from 'node:fs'
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const dbFile = path.join(here, '.tmp', 'e2e.db')
|
||||
const serverDir = path.join(here, '..', '..', 'server')
|
||||
|
||||
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||
try { rmSync(f, { force: true }) } catch {}
|
||||
}
|
||||
|
||||
// Build once (no watcher) — the resulting process is a single killable node.
|
||||
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
TREK_DB_FILE: dbFile,
|
||||
ADMIN_EMAIL: 'e2e@trek.local',
|
||||
ADMIN_PASSWORD: 'E2eTest12345!',
|
||||
PORT: '3001',
|
||||
NODE_ENV: 'development',
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
const stop = () => { try { child.kill() } catch {} }
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
process.on('exit', stop)
|
||||
child.on('exit', code => process.exit(code ?? 0))
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||
// mounts, proving the day-plan/map shell renders rather than crashing on load.
|
||||
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// Create a trip to open.
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
const title = `E2E Planner ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
// Open it from the dashboard.
|
||||
await page.getByText(title).first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
// The planner shows a Leaflet map once mounted (past the splash screen).
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import js from '@eslint/js';
|
||||
|
||||
import gitignore from 'eslint-config-flat-gitignore';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
|
||||
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
|
||||
// eslint-plugin-react (not a dependency here). The rule is a no-op.
|
||||
const reactStub = {
|
||||
rules: {
|
||||
'no-danger': {
|
||||
meta: { schema: [] },
|
||||
create() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default tseslint.config(
|
||||
gitignore({ strict: false }),
|
||||
{
|
||||
ignores: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'coverage',
|
||||
'public',
|
||||
'test-results',
|
||||
'playwright-report',
|
||||
'e2e/**',
|
||||
'scripts/**',
|
||||
'**/*.config.js',
|
||||
'**/*.config.ts',
|
||||
'**/*.config.mjs',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
react: reactStub,
|
||||
},
|
||||
rules: {
|
||||
'react/no-danger': 'off',
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// --- Severities tuned to keep CI green on a codebase that was never linted ---
|
||||
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
|
||||
|
||||
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||
'@typescript-eslint/no-this-alias': 'warn',
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
|
||||
|
||||
// js.recommended rules with pre-existing hits.
|
||||
'no-empty': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
'no-useless-assignment': 'warn',
|
||||
'preserve-caught-error': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
|
||||
Generated
-9772
File diff suppressed because it is too large
Load Diff
+55
-11
@@ -1,43 +1,87 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.8.0",
|
||||
"name": "@trek/client",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:check": "eslint .",
|
||||
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||
"e2e": "playwright test",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0"
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E harness for TREK's critical user flows (FE7).
|
||||
*
|
||||
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
|
||||
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
|
||||
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
|
||||
* /ws to the backend. Tests run serially against one worker so they share the
|
||||
* single seeded database deterministically.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
// Unauthenticated flows (login, register, public share) — no stored session.
|
||||
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
|
||||
// One-time login that persists a session for the authenticated flows.
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'app',
|
||||
testMatch: /\.spec\.ts/,
|
||||
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
// Always start our own backend (never reuse) so the isolated test DB is
|
||||
// reset + reseeded on every run, regardless of any stray dev server.
|
||||
command: 'node e2e/server-launch.mjs',
|
||||
port: 3001,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,44 @@
|
||||
// Guards the "Page = wiring container + data hook" convention (see
|
||||
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||
//
|
||||
// We scan only the default-export component body (from `export default function`
|
||||
// up to the next top-level `function` declaration or EOF), so presentational
|
||||
// sub-components and helper hooks living in the same file are not flagged.
|
||||
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||
|
||||
const violations = []
|
||||
for (const file of readdirSync(pagesDir)) {
|
||||
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||
const lines = src.split('\n')
|
||||
const start = lines.findIndex(l => /export default function/.test(l))
|
||||
if (start === -1) continue
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (bannedRe.test(lines[i])) {
|
||||
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
|
||||
for (const v of violations) console.error(' ' + v)
|
||||
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Page pattern OK — no state/effect logic in page containers.')
|
||||
@@ -0,0 +1,322 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../tests/helpers/msw/server'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { resetAllStores } from '../tests/helpers/store'
|
||||
import { buildUser, buildSettings } from '../tests/helpers/factories'
|
||||
import App from './App'
|
||||
|
||||
// ── Mock page components ───────────────────────────────────────────────────────
|
||||
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }))
|
||||
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }))
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }))
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }))
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }))
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }))
|
||||
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }))
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }))
|
||||
|
||||
// Prevent WebSocket side effects from the notification listener
|
||||
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
|
||||
useInAppNotificationListener: vi.fn(),
|
||||
}))
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderApp(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds authStore with sensible defaults for a test, replacing loadUser with a
|
||||
* no-op spy so the MSW /api/auth/me response does not overwrite the seeded state.
|
||||
*/
|
||||
function seedAuth(overrides: Record<string, unknown> = {}) {
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
appRequireMfa: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
// ── RootRedirect ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RootRedirect', () => {
|
||||
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
|
||||
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||
renderApp('/')
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — unauthenticated', () => {
|
||||
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/trips/42')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — loading state', () => {
|
||||
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
|
||||
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||
renderApp('/dashboard')
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — MFA enforcement', () => {
|
||||
it('FE-COMP-APP-007: redirects to /settings?mfa=required when appRequireMfa is true and MFA is disabled', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: false }),
|
||||
})
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: false }),
|
||||
})
|
||||
renderApp('/settings')
|
||||
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
appRequireMfa: true,
|
||||
user: buildUser({ mfa_enabled: true }),
|
||||
})
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
|
||||
|
||||
describe('ProtectedRoute — admin role check', () => {
|
||||
it('FE-COMP-APP-010: /admin redirects to /dashboard for non-admin user', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
user: buildUser({ role: 'user' }),
|
||||
})
|
||||
renderApp('/admin')
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
|
||||
seedAuth({
|
||||
isAuthenticated: true,
|
||||
user: buildUser({ role: 'admin' }),
|
||||
})
|
||||
renderApp('/admin')
|
||||
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── Public routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Public routes', () => {
|
||||
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/login')
|
||||
expect(screen.getByText('Login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/shared/sometoken')
|
||||
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/does-not-exist')
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
// ── App — on-mount effects ─────────────────────────────────────────────────────
|
||||
|
||||
describe('App — on-mount effects', () => {
|
||||
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||
renderApp('/dashboard')
|
||||
expect(loadUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||
renderApp('/shared/token123')
|
||||
expect(loadUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
|
||||
let configCalled = false
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => {
|
||||
configCalled = true
|
||||
return HttpResponse.json({})
|
||||
})
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(configCalled).toBe(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
|
||||
)
|
||||
const setDemoMode = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setDemoMode,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ loadSettings })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
// ── Dark mode effects ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Dark mode effects', () => {
|
||||
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
|
||||
renderApp('/dashboard')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
|
||||
renderApp('/shared/tok')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
|
||||
// matchMedia stub returns matches: false by default (from setup.ts)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
|
||||
renderApp('/dashboard')
|
||||
// With matches: false, dark should NOT be added
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Version cache-busting ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Version cache-busting', () => {
|
||||
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, reload },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
+81
-8
@@ -2,7 +2,10 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -10,24 +13,36 @@ import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -43,7 +58,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -59,7 +75,16 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
@@ -77,20 +102,34 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
loadUser()
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
// If the persist snapshot already has an authenticated user, validate
|
||||
// silently so the PWA shell renders immediately without a spinner.
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||
if (alreadyAuthenticated) {
|
||||
useAuthStore.setState({ isLoading: false })
|
||||
loadUser({ silent: true })
|
||||
} else {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
@@ -122,9 +161,15 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
@@ -154,14 +199,26 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -218,6 +275,22 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
|
||||
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
|
||||
if (!url) return url
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resource-token', {
|
||||
|
||||
+501
-103
@@ -1,53 +1,243 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
unreadCountResultSchema, type UnreadCountResult,
|
||||
channelTestResultSchema,
|
||||
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||
type FileUpdateRequest, type FileLinkRequest,
|
||||
type CreateTagRequest, type UpdateTagRequest,
|
||||
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||
type PlaceImportListRequest,
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
* dev, and never throwing. A drift between the server contract and the client's
|
||||
* expected shape is surfaced as a console warning during development; in
|
||||
* production (and on any mismatch) the data passes through untouched, so adding
|
||||
* validation can never break a working call. This is the typed-request helper
|
||||
* the FE adopts per domain as each backend module lands on @trek/shared.
|
||||
*/
|
||||
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
||||
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data as z.infer<S>
|
||||
}
|
||||
|
||||
/**
|
||||
* Same dev-only drift check as parseInDev, but passes the payload straight
|
||||
* through with its original inferred type instead of the schema type. Use this
|
||||
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
||||
* adds the development contract-drift warning without retyping the public
|
||||
* surface (so it can never break a consumer that worked before).
|
||||
*/
|
||||
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
en: 'Too many attempts. Please try again later.',
|
||||
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||
zh: '尝试次数过多,请稍后再试。',
|
||||
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||
it: 'Troppi tentativi. Riprova più tardi.',
|
||||
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add socket ID
|
||||
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||
const publicPrefixes = ['/shared/', '/public/']
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
async function unregisterSWAndReload(): Promise<void> {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg) await reg.unregister()
|
||||
} catch { /* ignore */ }
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
window.location.href = '/login'
|
||||
(response) => {
|
||||
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||
// as a CORS error with no response object. Probe the health endpoint to
|
||||
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
error.message = translated
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
@@ -61,92 +251,181 @@ export const authApi = {
|
||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
passkey: {
|
||||
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
||||
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
||||
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
|
||||
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
|
||||
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
|
||||
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
|
||||
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: number
|
||||
name: string | null
|
||||
device_type: string | null
|
||||
backed_up: boolean
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export const oauthApi = {
|
||||
/** Validate OAuth authorize params — called by consent page on load */
|
||||
validate: (params: {
|
||||
response_type: string
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
resource?: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
authorize: (body: {
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
resource?: string
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
|
||||
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
||||
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const todoApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => apiClient.get('/categories').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -155,6 +434,7 @@ export const adminApi = {
|
||||
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
|
||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||
@@ -164,6 +444,14 @@ export const adminApi = {
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||
@@ -179,37 +467,123 @@ export const adminApi = {
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
|
||||
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||
|
||||
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
|
||||
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||
|
||||
// Trips (sync sources)
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
|
||||
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||
|
||||
// Entries
|
||||
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
// Cover
|
||||
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
|
||||
// Contributors
|
||||
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
|
||||
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
|
||||
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -217,66 +591,89 @@ export const filesApi = {
|
||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
|
||||
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
const body: SettingUpsertRequest = { key, value }
|
||||
return apiClient.put('/settings', body).then(r => r.data)
|
||||
},
|
||||
setBulk: (settings: Record<string, unknown>) => {
|
||||
const body: SettingsBulkRequest = { settings }
|
||||
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const collabApi = {
|
||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -316,28 +713,29 @@ export const shareApi = {
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
||||
unreadCount: (): Promise<UnreadCountResult> =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: NotificationRespondRequest['response']) =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
@@ -0,0 +1,102 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
|
||||
|
||||
describe('SCOPE_GROUPS', () => {
|
||||
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
|
||||
const expected = [
|
||||
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||
'places:read', 'places:write',
|
||||
'atlas:read', 'atlas:write',
|
||||
'packing:read', 'packing:write',
|
||||
'todos:read', 'todos:write',
|
||||
'budget:read', 'budget:write',
|
||||
'reservations:read', 'reservations:write',
|
||||
'collab:read', 'collab:write',
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
|
||||
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
|
||||
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
|
||||
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
|
||||
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCOPE_GROUP_NAMES', () => {
|
||||
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
|
||||
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
|
||||
const expected = [
|
||||
'oauth.scope.group.trips',
|
||||
'oauth.scope.group.places',
|
||||
'oauth.scope.group.packing',
|
||||
'oauth.scope.group.budget',
|
||||
]
|
||||
for (const g of expected) {
|
||||
expect(SCOPE_GROUP_NAMES).toContain(g)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getScopesByGroup', () => {
|
||||
const identity = (key: string) => key
|
||||
|
||||
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
|
||||
const groups = getScopesByGroup(identity)
|
||||
// Every scope must appear exactly once across all groups
|
||||
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
|
||||
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
|
||||
for (const scope of ALL_SCOPES) {
|
||||
expect(allScopesInGroups).toContain(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
|
||||
const groups = getScopesByGroup(identity)
|
||||
for (const items of Object.values(groups)) {
|
||||
for (const item of items) {
|
||||
expect(item.scope).toBeTruthy()
|
||||
expect(item.label).toBeTruthy()
|
||||
expect(item.description).toBeTruthy()
|
||||
expect(item.group).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
|
||||
const groups = getScopesByGroup(identity)
|
||||
const tripsGroup = groups['oauth.scope.group.trips']
|
||||
expect(tripsGroup).toBeDefined()
|
||||
const scopeNames = tripsGroup.map(s => s.scope)
|
||||
expect(scopeNames).toContain('trips:read')
|
||||
expect(scopeNames).toContain('trips:write')
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
|
||||
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
|
||||
const groups = getScopesByGroup(t)
|
||||
expect(groups['Trips']).toBeDefined()
|
||||
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// Human-readable scope definitions for the OAuth consent page.
|
||||
// Must stay in sync with server/src/mcp/scopes.ts
|
||||
|
||||
export interface ScopeInfo {
|
||||
label: string
|
||||
description: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export interface ScopeKeys {
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
groupKey: string
|
||||
}
|
||||
|
||||
export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
|
||||
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
|
||||
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
|
||||
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
|
||||
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
|
||||
'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' },
|
||||
'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' },
|
||||
'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' },
|
||||
'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' },
|
||||
'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' },
|
||||
'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' },
|
||||
'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' },
|
||||
'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' },
|
||||
'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' },
|
||||
'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' },
|
||||
'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' },
|
||||
'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' },
|
||||
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
||||
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
||||
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
||||
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
// Group all scopes for the client registration form
|
||||
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
|
||||
|
||||
export function getScopesByGroup(t: (key: string) => string): Record<string, Array<{ scope: string } & ScopeInfo>> {
|
||||
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
|
||||
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
|
||||
const group = t(keys.groupKey)
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -13,6 +13,8 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||
let preReconnectHook: (() => Promise<void>) | null = null
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook that runs (and is awaited) before the refetch callback
|
||||
* fires on WS reconnect. Use this to flush the mutation queue so queued
|
||||
* local writes reach the server before the app reads back canonical state.
|
||||
* Pass null to clear.
|
||||
*/
|
||||
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
|
||||
preReconnectHook = fn
|
||||
}
|
||||
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
})
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
const doRefetch = () => {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Flush queued mutations first so local writes land before server read-back.
|
||||
// If the hook fails, still refetch to keep the UI correct.
|
||||
if (preReconnectHook) {
|
||||
preReconnectHook().catch(console.error).then(doRefetch)
|
||||
} else {
|
||||
doRefetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AddonManager from './AddonManager';
|
||||
|
||||
function buildAddon(overrides = {}) {
|
||||
return {
|
||||
id: 'todo',
|
||||
name: 'Todo List',
|
||||
description: 'Track tasks',
|
||||
icon: 'ListChecks',
|
||||
type: 'trip',
|
||||
enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
})
|
||||
);
|
||||
render(<AddonManager />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('No addons available');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', name: 'Todo List', type: 'trip' })] })
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
// Section header contains "Trip" and "Available as a tab within each trip"
|
||||
expect(screen.getAllByText(/Trip/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Available as a tab within each trip/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-004: global and integration sections render when present', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'global1', name: 'Global Feature', type: 'global' }),
|
||||
buildAddon({ id: 'int1', name: 'Integration Feature', type: 'integration' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Global Feature');
|
||||
expect(screen.getAllByText(/Global/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Integration/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
|
||||
// Before click - disabled state (border-primary bg)
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
// After click - success toast
|
||||
await screen.findByText('Addon updated');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||
),
|
||||
http.put('/api/admin/addons/todo', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
// Error toast appears
|
||||
await screen.findByText('Failed to update addon');
|
||||
|
||||
// The disabled text should be back after rollback
|
||||
await waitFor(() => {
|
||||
const disabledTexts = screen.getAllByText('Disabled');
|
||||
expect(disabledTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-007: bag tracking sub-toggle renders when packing addon is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggle = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
);
|
||||
await screen.findByText('Bag Tracking');
|
||||
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||
await user.click(allBtns[allBtns.length - 1]);
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-008: bag tracking hidden when packing addon is disabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
|
||||
)
|
||||
);
|
||||
render(
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
|
||||
);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||
)
|
||||
);
|
||||
render(<AddonManager bagTrackingEnabled={false} />);
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
expect(screen.getByText('Pexels')).toBeInTheDocument();
|
||||
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [buildAddon({ id: 'mystery', name: 'Mystery Addon', icon: 'NonExistentIcon', type: 'trip' })],
|
||||
})
|
||||
)
|
||||
);
|
||||
// Should not throw; Puzzle icon is used as fallback
|
||||
expect(() => render(<AddonManager />)).not.toThrow();
|
||||
await screen.findByText('Mystery Addon');
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,31 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -15,7 +36,17 @@ interface Addon {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
toggle: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
@@ -28,13 +59,22 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState([])
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +93,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (addon) => {
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
@@ -68,9 +108,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -83,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -100,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -110,13 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={onToggleBagTracking}
|
||||
@@ -128,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -136,14 +242,48 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -151,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -171,8 +311,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
@@ -187,51 +329,55 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
|
||||
|
||||
const TOKEN_1 = {
|
||||
id: 1,
|
||||
name: 'CI Token',
|
||||
token_prefix: 'trek_abc',
|
||||
created_at: '2025-01-15T00:00:00Z',
|
||||
last_used_at: null,
|
||||
user_id: 10,
|
||||
username: 'alice',
|
||||
};
|
||||
|
||||
const TOKEN_2 = {
|
||||
id: 2,
|
||||
name: 'Ops Token',
|
||||
token_prefix: 'trek_xyz',
|
||||
created_at: '2025-03-01T00:00:00Z',
|
||||
last_used_at: '2025-04-01T00:00:00Z',
|
||||
user_id: 11,
|
||||
username: 'bob',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('AdminMcpTokensPanel', () => {
|
||||
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ tokens: [] });
|
||||
})
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No MCP tokens have been created yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
// token_prefix is rendered as `{token.token_prefix}...` — two adjacent text nodes
|
||||
expect(screen.getByText(/trek_abc/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/trek_xyz/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
// Dialog Delete button has visible text "Delete"; trash icon buttons have no text content
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||
|
||||
const backdrop = document.querySelector('.fixed.inset-0');
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
await user.click(backdrop!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('CI Token')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||
await screen.findByText('Token deleted');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||
),
|
||||
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('CI Token');
|
||||
|
||||
const deleteButtons = screen.getAllByTitle('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
await user.click(screen.getByText('Delete'));
|
||||
|
||||
await screen.findByText('Failed to delete token');
|
||||
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/mcp-tokens', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
})
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
client_name: 'Claude Desktop',
|
||||
username: 'alice',
|
||||
scopes: ['trips:read', 'budget:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('trips:read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('App');
|
||||
// "+1 more" button should appear
|
||||
const moreBtn = await screen.findByText(/\+1 more/);
|
||||
expect(moreBtn).toBeInTheDocument();
|
||||
await user.click(moreBtn);
|
||||
// After expand, "show less" appears
|
||||
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
|
||||
// Confirmation modal opens
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
user_id: number
|
||||
username: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
@@ -14,21 +26,49 @@ interface AdminMcpToken {
|
||||
username: string
|
||||
}
|
||||
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [tokensLoading, setTokensLoading] = useState(true)
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
|
||||
const toggleScopes = (id: number) =>
|
||||
setExpandedScopes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.oauthSessions()
|
||||
.then(d => setSessions(d.sessions || []))
|
||||
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||
.finally(() => setSessionsLoading(false))
|
||||
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
.finally(() => setTokensLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await adminApi.revokeOAuthSession(id)
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
setRevokeConfirmId(null)
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.oauthSessions.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
@@ -43,68 +83,165 @@ export default function AdminMcpTokensPanel() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import AuditLogPanel from './AuditLogPanel';
|
||||
|
||||
const ENTRY_1 = {
|
||||
id: 1,
|
||||
created_at: '2025-06-01T10:30:00Z',
|
||||
user_id: 5,
|
||||
username: 'alice',
|
||||
user_email: 'alice@example.com',
|
||||
action: 'trip.create',
|
||||
resource: '/trips/42',
|
||||
details: { title: 'Test' },
|
||||
ip: '127.0.0.1',
|
||||
};
|
||||
|
||||
const ENTRY_2 = {
|
||||
id: 2,
|
||||
created_at: '2025-06-02T11:00:00Z',
|
||||
user_id: 6,
|
||||
username: 'bob',
|
||||
user_email: 'bob@example.com',
|
||||
action: 'trip.delete',
|
||||
resource: '/trips/43',
|
||||
details: null,
|
||||
ip: '10.0.0.1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('AuditLogPanel', () => {
|
||||
it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
}),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [], total: 0 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('No audit entries yet.');
|
||||
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resource')).toBeInTheDocument();
|
||||
expect(screen.getByText('IP')).toBeInTheDocument();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('/trips/42')).toBeInTheDocument();
|
||||
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => {
|
||||
const entries = [
|
||||
{ ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' },
|
||||
{ ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' },
|
||||
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
|
||||
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries, total: 4 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.username');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('#7')).toBeInTheDocument();
|
||||
// '—' appears multiple times (null resource, null ip for some, null user) — just check it exists
|
||||
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => {
|
||||
const entry = {
|
||||
...ENTRY_1,
|
||||
id: 20,
|
||||
action: 'a.nulls',
|
||||
resource: null,
|
||||
ip: null,
|
||||
details: null,
|
||||
};
|
||||
const entryEmptyDetails = {
|
||||
...ENTRY_1,
|
||||
id: 21,
|
||||
action: 'a.emptyobj',
|
||||
resource: '/ok',
|
||||
ip: '1.2.3.4',
|
||||
details: {},
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('a.nulls');
|
||||
// null resource, null ip, null details → three '—' for entry; empty obj details → another '—'
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
const loadMoreBtn = screen.getByText('Load more');
|
||||
expect(loadMoreBtn).toBeInTheDocument();
|
||||
await user.click(loadMoreBtn);
|
||||
await screen.findByText('trip.delete');
|
||||
expect(screen.getByText('trip.create')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () =>
|
||||
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
|
||||
),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
await screen.findByText('trip.create');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => {
|
||||
const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' };
|
||||
const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' };
|
||||
const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' };
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 });
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
|
||||
}
|
||||
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
// Initial load: PAGE1_ENTRY visible, load more
|
||||
await screen.findByText('phase1.action');
|
||||
const loadMoreBtn = screen.getByText('Load more');
|
||||
await user.click(loadMoreBtn);
|
||||
await screen.findByText('phase2.action');
|
||||
// Now refresh
|
||||
const refreshBtn = screen.getByText('Refresh');
|
||||
await user.click(refreshBtn);
|
||||
// After refresh, only REFRESH_ENTRY should be visible
|
||||
await screen.findByText('phase3.refresh');
|
||||
await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('phase2.action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/audit-log', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ entries: [], total: 0 });
|
||||
}),
|
||||
);
|
||||
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||
const refreshBtn = screen.getByText('Refresh');
|
||||
expect(refreshBtn.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -100,54 +100,53 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||
<p className="text-xs m-0 text-content-faint">
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||
<tr className="border-b text-left border-edge-secondary">
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -160,8 +159,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import BackupPanel from './BackupPanel'
|
||||
import { ToastContainer } from '../shared/Toast'
|
||||
|
||||
const manualBackup = {
|
||||
filename: 'backup-2025-01-15.zip',
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
size: 2048000,
|
||||
}
|
||||
const autoBackup = {
|
||||
filename: 'auto-backup-2025-02-01.zip',
|
||||
created_at: '2025-02-01T02:00:00Z',
|
||||
size: 1024000,
|
||||
}
|
||||
|
||||
function defaultBackupHandlers() {
|
||||
return [
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function getToggleButton() {
|
||||
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
|
||||
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
|
||||
return label.querySelector('button') as HTMLElement
|
||||
}
|
||||
|
||||
describe('BackupPanel', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
server.use(...defaultBackupHandlers())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
server.resetHandlers()
|
||||
})
|
||||
|
||||
// BKP-001: Loading state
|
||||
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
return HttpResponse.json({ backups: [] })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-002: Empty state
|
||||
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No backups yet')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Create first backup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-003: Backup list renders filename, size, and date
|
||||
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-004: Auto-backup badge shown for auto-backup filenames
|
||||
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-005: Create backup success
|
||||
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByTitle('Create Backup'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-006: Restore opens confirmation modal
|
||||
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-007: Cancel dismisses modal without calling restore API
|
||||
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
|
||||
const user = userEvent.setup()
|
||||
let restoreCalled = false
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => {
|
||||
restoreCalled = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(restoreCalled).toBe(false)
|
||||
})
|
||||
|
||||
// BKP-008: Backdrop click dismisses modal
|
||||
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||
})
|
||||
// Click the backdrop overlay (the fixed-position div)
|
||||
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
|
||||
expect(backdrop).toBeTruthy()
|
||||
fireEvent.click(backdrop!)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-009: Successful restore calls API and reloads after 1500ms
|
||||
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
|
||||
const reloadMock = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
|
||||
await user.click(screen.getByText('Yes, restore'))
|
||||
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
|
||||
|
||||
// Wait for the 1500ms reload timer to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||
expect(reloadMock).toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
}, 20000)
|
||||
|
||||
// BKP-010: Delete backup with confirm dialog
|
||||
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
const trashBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
expect(trashBtn).toBeTruthy()
|
||||
await user.click(trashBtn!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-011: Auto-backup enable toggle shows interval controls
|
||||
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
|
||||
await user.click(getToggleButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hourly')).toBeInTheDocument()
|
||||
expect(screen.getByText('Daily')).toBeInTheDocument()
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-012: Weekly interval shows day-of-week picker
|
||||
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByText('Weekly'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sun')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sat')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-013: Save auto-settings calls API and shows toast
|
||||
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('Weekly'))
|
||||
await waitFor(() => {
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(saveBtn).not.toBeDisabled()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-014: Save button disabled until settings changed
|
||||
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||
})
|
||||
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(saveBtn).toBeDisabled()
|
||||
await user.click(getToggleButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -186,8 +186,8 @@ export default function BackupPanel() {
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -310,8 +310,8 @@ export default function BackupPanel() {
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
@@ -458,7 +458,8 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
@@ -468,14 +469,14 @@ export default function BackupPanel() {
|
||||
>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
@@ -505,7 +506,8 @@ export default function BackupPanel() {
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
|
||||
import CategoryManager from './CategoryManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('CategoryManager', () => {
|
||||
it('FE-COMP-CAT-001: renders without crashing', () => {
|
||||
render(<CategoryManager />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Categories');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-006: shows existing categories from API', async () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [
|
||||
buildCategory({ name: 'Museum' }),
|
||||
buildCategory({ name: 'Restaurant' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-007: clicking Create submits POST API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/categories', async ({ request }) => {
|
||||
postCalled = true;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
category: buildCategory({ name: String(body.name) }),
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
const nameInput = screen.getByPlaceholderText('Category name');
|
||||
await user.type(nameInput, 'Parks');
|
||||
await user.click(screen.getByText('Create'));
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
// Edit button is icon-only (no title) — find all buttons and click the first action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Buttons: [New Category, ...action buttons for the category]
|
||||
// The edit button is the first action button in the category row (Edit2 icon)
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[0]);
|
||||
// Name input pre-filled with category name
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-009: delete button triggers DELETE API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
|
||||
),
|
||||
http.delete('/api/categories/9', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
// Delete button is icon-only (Trash2, no title) — find the second action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Manage categories for places');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [buildCategory({ name: 'Cat1' }), buildCategory({ name: 'Cat2' })],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Cat1');
|
||||
await screen.findByText('Cat2');
|
||||
// Both categories rendered
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-012: Cancel button in form hides the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -191,8 +191,8 @@ export default function CategoryManager() {
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
setDefaults(updated)
|
||||
toast.success(t('admin.defaultSettings.saved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import DevNotificationsPanel from './DevNotificationsPanel';
|
||||
|
||||
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('DevNotificationsPanel', () => {
|
||||
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for async data to populate conditional sections
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.getByText('Type Testing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const options = Array.from(tripSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent);
|
||||
expect(labels).toContain('Paris Adventure');
|
||||
expect(labels).toContain('Tokyo Trip');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// Second combobox is the user selector (first is trip selector)
|
||||
const userSelect = selects[1];
|
||||
const options = Array.from(userSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent ?? '');
|
||||
expect(labels.some(l => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some(l => l.includes('alice'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
expect(capturedBody).toMatchObject({
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: ADMIN_USER.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ ok: true }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Sent: simple-me');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async () => {
|
||||
await new Promise(() => {}); // never resolves — simulates in-flight
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText(/failed|error/i);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||
o => o.textContent === 'Tokyo Trip',
|
||||
)!;
|
||||
const tokyoId = Number(tokyoOption.value);
|
||||
|
||||
await user.selectOptions(tripSelect, 'Tokyo Trip');
|
||||
await user.click(screen.getByText('booking_change').closest('button')!);
|
||||
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
expect(capturedBody!.targetId).toBe(tokyoId);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for user data to confirm async effects have settled
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
@@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const send = async (label: string, payload: Record<string, unknown>) => {
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
@@ -49,295 +53,230 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: 'Simple → Me',
|
||||
icon: Bell,
|
||||
color: '#6366f1',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Boolean → Me',
|
||||
icon: CheckCircle,
|
||||
color: '#10b981',
|
||||
payload: {
|
||||
type: 'boolean',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Navigate → Me',
|
||||
icon: Navigation,
|
||||
color: '#f59e0b',
|
||||
payload: {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Simple → Admins',
|
||||
icon: Zap,
|
||||
color: '#ef4444',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
title_key: 'notifications.test.adminTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.adminText',
|
||||
text_params: { actor: user?.username || 'Admin' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
||||
</p>
|
||||
|
||||
{/* Quick-fire buttons */}
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{buttons.map(btn => {
|
||||
const Icon = btn.icon
|
||||
return (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => send(btn.label, btn.payload)}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${btn.color}20`, color: btn.color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
||||
{btn.payload.type} · {btn.payload.scope}
|
||||
</p>
|
||||
</div>
|
||||
{sending === btn.label && (
|
||||
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip-scoped notifications */}
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={trip.id}>{trip.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Simple → Trip', {
|
||||
type: 'simple',
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
|
||||
<Send className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Navigate → Trip', {
|
||||
type: 'navigate',
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: `/trips/${selectedTripId}`,
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-scoped notifications */}
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'simple',
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#06b6d420', color: '#06b6d4' }}>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'boolean',
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#10b98120', color: '#10b981' }}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import GitHubPanel from './GitHubPanel';
|
||||
|
||||
function buildRelease(overrides = {}) {
|
||||
const id = Math.random();
|
||||
return {
|
||||
id,
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'Initial Release',
|
||||
body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`',
|
||||
published_at: '2025-01-15T12:00:00Z',
|
||||
created_at: '2025-01-15T12:00:00Z',
|
||||
prerelease: false,
|
||||
author: { login: 'mauriceboe' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
|
||||
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
|
||||
);
|
||||
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
|
||||
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('GitHubPanel', () => {
|
||||
it('FE-ADMIN-GH-001: support link cards always render', async () => {
|
||||
render(<GitHubPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discord')).toBeInTheDocument();
|
||||
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feature Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => {
|
||||
render(<GitHubPanel />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||
|
||||
const kofi = screen.getByText('Ko-fi').closest('a')!;
|
||||
expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
|
||||
expect(kofi).toHaveAttribute('target', '_blank');
|
||||
expect(kofi).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
const bmc = screen.getByText('Buy Me a Coffee').closest('a')!;
|
||||
expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
|
||||
expect(bmc).toHaveAttribute('target', '_blank');
|
||||
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
const discord = screen.getByText('Discord').closest('a')!;
|
||||
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
|
||||
expect(discord).toHaveAttribute('target', '_blank');
|
||||
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
// The Loader2 spinner is rendered while loading=true
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () =>
|
||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('Failed to load releases');
|
||||
// Timeline should not be rendered
|
||||
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.0.0');
|
||||
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||
// Author label
|
||||
const authorLabels = screen.getAllByText(/mauriceboe/);
|
||||
expect(authorLabels.length).toBeGreaterThan(0);
|
||||
// Some date should be visible (non-empty)
|
||||
const dateEls = document.querySelectorAll('[class*="text-"]');
|
||||
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
|
||||
expect(dateTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
|
||||
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
|
||||
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v2.0.0');
|
||||
const latestBadges = screen.getAllByText('Latest');
|
||||
expect(latestBadges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
|
||||
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
render(<GitHubPanel isPrerelease={true} />);
|
||||
await screen.findByText('v3.0.0-beta.1');
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-008: expand/collapse release notes', async () => {
|
||||
const r = buildRelease({
|
||||
id: 20,
|
||||
tag_name: 'v1.5.0',
|
||||
body: '- Fixed bug\n- Another fix',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.5.0');
|
||||
|
||||
const showBtn = screen.getByText('Show details');
|
||||
expect(showBtn).toBeInTheDocument();
|
||||
|
||||
// Body not visible yet
|
||||
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument();
|
||||
|
||||
// Expand
|
||||
await user.click(showBtn);
|
||||
await screen.findByText('Fixed bug');
|
||||
expect(screen.getByText('Hide details')).toBeInTheDocument();
|
||||
|
||||
// Collapse
|
||||
await user.click(screen.getByText('Hide details'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Show details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => {
|
||||
const r = buildRelease({
|
||||
id: 30,
|
||||
tag_name: 'v1.6.0',
|
||||
body: '- list item\n- **bold text**\n- `inline code`',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.6.0');
|
||||
|
||||
await user.click(screen.getByText('Show details'));
|
||||
await screen.findByText('list item');
|
||||
|
||||
// list item is inside a <li>
|
||||
const listItem = screen.getByText('list item');
|
||||
expect(listItem.closest('li')).toBeInTheDocument();
|
||||
|
||||
// Bold text rendered as <strong>
|
||||
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
|
||||
expect(container.querySelector('strong')).toBeInTheDocument();
|
||||
expect(container.querySelector('strong')!.textContent).toBe('bold text');
|
||||
|
||||
// Code rendered as <code>
|
||||
expect(container.querySelector('code')).toBeInTheDocument();
|
||||
expect(container.querySelector('code')!.textContent).toBe('inline code');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText(`v1.0.0`);
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v0.0.0');
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => {
|
||||
const r = buildRelease({
|
||||
id: 40,
|
||||
tag_name: 'v1.7.0',
|
||||
body: 'This is a plain paragraph without any markdown syntax.',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
await screen.findByText('This is a plain paragraph without any markdown syntax.');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
|
||||
const r = buildRelease({
|
||||
id: 41,
|
||||
tag_name: 'v1.8.0',
|
||||
body: '- [click here](https://example.com)',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.8.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
const link = await screen.findByText('click here');
|
||||
expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => {
|
||||
const r = buildRelease({
|
||||
id: 42,
|
||||
tag_name: 'v1.9.0',
|
||||
body: '- [evil](javascript:alert(1))',
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.9.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
const link = await screen.findByText('evil');
|
||||
const anchor = link.closest('a') ?? link;
|
||||
// The unsafe href is replaced with '#'
|
||||
expect(anchor).toHaveAttribute('href', '#');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => {
|
||||
render(<GitHubPanel />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||
|
||||
const kofiLink = screen.getByText('Ko-fi').closest('a')!;
|
||||
fireEvent.mouseEnter(kofiLink);
|
||||
fireEvent.mouseLeave(kofiLink);
|
||||
|
||||
const discordLink = screen.getByText('Discord').closest('a')!;
|
||||
fireEvent.mouseEnter(discordLink);
|
||||
fireEvent.mouseLeave(discordLink);
|
||||
|
||||
const bugLink = screen.getByText('Report a Bug').closest('a')!;
|
||||
fireEvent.mouseEnter(bugLink);
|
||||
fireEvent.mouseLeave(bugLink);
|
||||
|
||||
const featureLink = screen.getByText('Feature Request').closest('a')!;
|
||||
fireEvent.mouseEnter(featureLink);
|
||||
fireEvent.mouseLeave(featureLink);
|
||||
|
||||
const wikiLink = screen.getByText('Wiki').closest('a')!;
|
||||
fireEvent.mouseEnter(wikiLink);
|
||||
fireEvent.mouseLeave(wikiLink);
|
||||
|
||||
const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!;
|
||||
fireEvent.mouseEnter(bmcLink);
|
||||
fireEvent.mouseLeave(bmcLink);
|
||||
|
||||
// All links still visible
|
||||
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get('page');
|
||||
if (page === '2') {
|
||||
return HttpResponse.json(PAGE_2);
|
||||
}
|
||||
return HttpResponse.json(PAGE_1);
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.0.0');
|
||||
|
||||
// All 10 items from page 1 visible
|
||||
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
|
||||
|
||||
// Click Load more
|
||||
await user.click(screen.getByText('Load more'));
|
||||
|
||||
// Wait for page 2 items to appear
|
||||
await screen.findByText('v0.0.0');
|
||||
|
||||
// Total: 10 from page 1 + 5 from page 2 = 15
|
||||
const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/);
|
||||
expect(tagEls.length).toBe(15);
|
||||
|
||||
// Load more should be hidden (PAGE_2 < 10)
|
||||
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
author: { login: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -61,7 +73,7 @@ export default function GitHubPanel() {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
@@ -90,14 +102,14 @@ export default function GitHubPanel() {
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
@@ -106,7 +118,7 @@ export default function GitHubPanel() {
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
@@ -124,85 +136,135 @@ export default function GitHubPanel() {
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
@@ -216,7 +278,7 @@ export default function GitHubPanel() {
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
@@ -236,36 +298,34 @@ export default function GitHubPanel() {
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
@@ -276,15 +336,14 @@ export default function GitHubPanel() {
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
@@ -303,8 +362,7 @@ export default function GitHubPanel() {
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import PackingTemplateManager from './PackingTemplateManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
|
||||
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
|
||||
|
||||
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
|
||||
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
|
||||
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', async () => {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
return HttpResponse.json({ templates: [] });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-002: shows empty state when no templates', async () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
expect(screen.queryAllByRole('button', { name: /chevron/i })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
// tmpl1 has 2 categories and 5 items
|
||||
expect(screen.getByText(/2 categories · 5 items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-004: clicking "+" shows create input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
const createBtn = screen.getByRole('button', { name: /new template/i });
|
||||
await user.click(createBtn);
|
||||
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-005: creates template on Enter and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, 'New Template{Enter}');
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
// "New Template" may appear both as the button label and the new list item
|
||||
await waitFor(() => expect(screen.getAllByText('New Template').length).toBeGreaterThanOrEqual(1));
|
||||
await screen.findByText('Template created');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-006: Escape dismisses create input without API call', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ template: { id: 99, name: 'Should Not Appear' } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, 'Test{Escape}');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
// Collapse by clicking again
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-009: deleting a template removes it from the list and shows toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
|
||||
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||
);
|
||||
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||
// The buttons layout in each row: [chevron, edit, delete]
|
||||
// We find rows first
|
||||
const beachTripRow = screen.getByText('Beach Trip').closest('div');
|
||||
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
|
||||
if (trashBtn) {
|
||||
await user.click(trashBtn);
|
||||
} else {
|
||||
// Fallback: find all red-hover buttons and click first
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||
await user.click(redBtns[0]);
|
||||
}
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
await screen.findByText('Template deleted');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-010: renaming a template inline updates the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
// Find the Edit2 button on the template row
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Fallback: find all slate-100-hover buttons
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Summer Packing{Enter}');
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
await screen.findByText('Summer Packing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () =>
|
||||
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
// Wait for expanded state (Add category button should appear)
|
||||
await screen.findByText('Add category');
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
await screen.findByText('Electronics');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
|
||||
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Click the "+" button on the Clothing category row
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||
await user.click(addItemBtn);
|
||||
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
await user.type(itemInput, 'Sandals');
|
||||
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||
await user.type(itemInput, '{Enter}');
|
||||
await screen.findByText('Sandals');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Find the Edit2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('hover:text-slate-700')
|
||||
);
|
||||
// Second button (after Plus) is Edit2
|
||||
await user.click(editBtns[1]);
|
||||
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
await screen.findByText('Shoes');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
|
||||
await user.click(trashBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/items/100', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||
b => b.className.includes('opacity-0')
|
||||
) as HTMLElement | undefined;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Directly click the first button in the item row
|
||||
const btns = itemRow.querySelectorAll('button');
|
||||
await user.click(btns[0] as HTMLElement);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top{Enter}');
|
||||
await screen.findByText('Tank Top');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the T-shirt row
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('opacity-0')
|
||||
);
|
||||
// Second opacity-0 button is the delete (trash) button
|
||||
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||
await user.click(trashBtn as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-017: Escape cancels add category without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Add category');
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Test{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-018: Escape cancels add item without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||
await user.click(addItemBtn);
|
||||
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
await user.type(itemInput, 'Test{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-019: Escape cancels template rename without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.type(input, '{Escape}');
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(putCalled).toBe(false);
|
||||
// Original name should be restored
|
||||
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-020: X button on create template input dismisses it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||
|
||||
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
|
||||
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PermissionsPanel from './PermissionsPanel';
|
||||
|
||||
// ── Fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
|
||||
|
||||
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
|
||||
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
|
||||
}
|
||||
|
||||
const SAMPLE_PERMISSIONS = [
|
||||
buildPermission('trip_create'),
|
||||
buildPermission('trip_edit'),
|
||||
buildPermission('trip_delete'),
|
||||
buildPermission('trip_archive'),
|
||||
buildPermission('trip_cover_upload'),
|
||||
buildPermission('member_manage'),
|
||||
buildPermission('file_upload'),
|
||||
buildPermission('file_edit'),
|
||||
buildPermission('file_delete'),
|
||||
buildPermission('place_edit'),
|
||||
buildPermission('day_edit'),
|
||||
buildPermission('reservation_edit'),
|
||||
buildPermission('budget_edit'),
|
||||
buildPermission('packing_edit'),
|
||||
buildPermission('collab_edit'),
|
||||
buildPermission('share_manage'),
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PermissionsPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Override the default handler (returns object) with correct array shape
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PermissionsPanel', () => {
|
||||
it('FE-ADMIN-PERM-001: loading spinner renders before data arrives', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ permissions: [] });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
// The form content (category headings) should not be present
|
||||
expect(screen.queryByText('Trip Management')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-002: permission categories and actions render after load', async () => {
|
||||
renderPanel();
|
||||
// Wait until loading is done — a category heading appears
|
||||
await screen.findByText('Trip Management');
|
||||
expect(screen.getByText('Member Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add / remove members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-003: "customized" badge visible when value differs from default', async () => {
|
||||
const perms = [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
|
||||
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
// Badge should appear once (for trip_create)
|
||||
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('customized')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-004: Save button is disabled until a value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Open the first CustomSelect trigger (shows current level "Trip members")
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
|
||||
// Pick an option different from the current one (current is trip_member → pick admin)
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-005: changing a value marks form dirty and enables Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Open first CustomSelect dropdown and select a different option
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
|
||||
const perms = [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // customized
|
||||
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Customized badge should be visible
|
||||
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
const resetButton = screen.getByRole('button', { name: /Reset to defaults/i });
|
||||
|
||||
await user.click(resetButton);
|
||||
|
||||
// Badge should disappear (value back to defaultLevel)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('customized')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save should be enabled (handleReset sets dirty=true)
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
await screen.findByText('Permission settings saved');
|
||||
// After successful save, dirty is cleared → Save disabled again
|
||||
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
await screen.findByText('Error');
|
||||
// Dirty unchanged → Save stays enabled
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
|
||||
let resolvePut!: () => void;
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolvePut = () =>
|
||||
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
// In-flight: button should be disabled and show Loader2 spinner
|
||||
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||
const loader = saveButton.querySelector('.animate-spin');
|
||||
expect(loader).toBeInTheDocument();
|
||||
|
||||
// Resolve the request
|
||||
resolvePut();
|
||||
await screen.findByText('Permission settings saved');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
});
|
||||
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
export const SPLIT_COLORS = [
|
||||
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
|
||||
export function widgetTheme(dark: boolean) {
|
||||
if (dark) return {
|
||||
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||
border: 'rgba(255,255,255,0.07)',
|
||||
text: '#ffffff',
|
||||
sub: 'rgba(255,255,255,0.6)',
|
||||
faint: 'rgba(255,255,255,0.4)',
|
||||
track: 'rgba(255,255,255,0.04)',
|
||||
divider: 'rgba(255,255,255,0.07)',
|
||||
iconBg: 'rgba(255,255,255,0.08)',
|
||||
iconBorder: 'rgba(255,255,255,0.12)',
|
||||
iconColor: 'rgba(255,255,255,0.9)',
|
||||
centerBg: '#17171d',
|
||||
flowBg: 'rgba(255,255,255,0.05)',
|
||||
flowBorder: 'rgba(255,255,255,0.07)',
|
||||
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||
rowHover: 'rgba(255,255,255,0.03)',
|
||||
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||
}
|
||||
return {
|
||||
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: 'rgba(15,23,42,0.08)',
|
||||
text: '#111827',
|
||||
sub: 'rgba(17,24,39,0.6)',
|
||||
faint: 'rgba(17,24,39,0.4)',
|
||||
track: 'rgba(15,23,42,0.05)',
|
||||
divider: 'rgba(15,23,42,0.08)',
|
||||
iconBg: 'rgba(15,23,42,0.05)',
|
||||
iconBorder: 'rgba(15,23,42,0.1)',
|
||||
iconColor: 'rgba(17,24,39,0.75)',
|
||||
centerBg: '#ffffff',
|
||||
flowBg: 'rgba(15,23,42,0.03)',
|
||||
flowBorder: 'rgba(15,23,42,0.08)',
|
||||
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||
rowHover: 'rgba(15,23,42,0.04)',
|
||||
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||
}
|
||||
}
|
||||
|
||||
export function hexLighten(hex: string, amount: number): string {
|
||||
const m = hex.replace('#', '').match(/.{2}/g)
|
||||
if (!m || m.length !== 3) return hex
|
||||
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
type NumOrNull = number | null | undefined
|
||||
|
||||
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
|
||||
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
|
||||
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
export function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Settlement and per-person APIs needed by BudgetPanel
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', () =>
|
||||
HttpResponse.json({ balances: [], flows: [] })
|
||||
),
|
||||
http.get('/api/trips/:id/budget/per-person', () =>
|
||||
HttpResponse.json({ summary: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
});
|
||||
|
||||
describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText(/Create categories and entries/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-005: renders category section header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Name');
|
||||
// 'Total' appears both as a table header and in the chart total label.
|
||||
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Budget');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||
await user.type(nameInput, 'Restaurant Dinner');
|
||||
const addBtn = screen.getByTitle('Add Reservation');
|
||||
await user.click(addBtn);
|
||||
await screen.findByText('Restaurant Dinner');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Test Item');
|
||||
// Delete button has title="Delete"
|
||||
expect(screen.getByTitle('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Item To Delete');
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel A');
|
||||
await screen.findByText('Hotel B');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// Each category appears in its section header and again in the breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// Component renders even in empty state
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Misc');
|
||||
// Row exists - EUR formatting would appear in values
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
// The add button is present
|
||||
expect(screen.getByTitle('Add Reservation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||
await user.type(nameInput, 'Pizza{Enter}');
|
||||
await screen.findByText('Pizza');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} tripMembers={[]} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Old Name');
|
||||
await user.click(screen.getByText('Old Name'));
|
||||
expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||
const b = await request.json() as Record<string, unknown>;
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ item: { ...item, name: b.name } });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Old Name');
|
||||
await user.click(screen.getByText('Old Name'));
|
||||
const input = screen.getByDisplayValue('Old Name');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Name');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
|
||||
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Dinner');
|
||||
// The formatted number appears in the InlineEditCell for total price (and grand total card)
|
||||
expect(screen.getAllByText('45.50').length).toBeGreaterThan(0);
|
||||
// The currency symbol appears (in category subtotal or grand total card)
|
||||
expect(screen.getAllByText(/€/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findAllByText('Flights');
|
||||
await screen.findByText('Flight to Paris');
|
||||
await user.click(screen.getByTitle('Delete Category'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByText('Flights').length).toBe(0);
|
||||
expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:test');
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
await user.click(screen.getByText('CSV'));
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Lunch');
|
||||
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
|
||||
expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.post('/api/trips/1/budget', () =>
|
||||
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
|
||||
)
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const input = await screen.findByPlaceholderText('Enter category name...');
|
||||
await user.type(input, 'Souvenirs{Enter}');
|
||||
await screen.findByText('Souvenirs');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', balance: -10, avatar_url: null },
|
||||
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
|
||||
],
|
||||
flows: [
|
||||
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Dinner');
|
||||
// Click the settlement toggle button (role button with name containing "settlement")
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
// alice and bob should appear in balances section
|
||||
await screen.findByText('alice');
|
||||
await screen.findByText('bob');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||
total_price: 75,
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/summary/per-person', () =>
|
||||
HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] })
|
||||
)
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'testuser', avatar_url: null },
|
||||
{ id: 2, username: 'other', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Shared Dinner');
|
||||
await screen.findByText('testuser');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Flight');
|
||||
await screen.findByText('Hotel');
|
||||
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Read Only Item');
|
||||
// In read-only mode the Delete button should not be visible
|
||||
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
await screen.findByText('2025-06-15');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Lunch');
|
||||
// Trigger settlement display
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
await screen.findByText('alice');
|
||||
// Avatar image should be rendered for alice
|
||||
const avatarImg = screen.getAllByRole('img');
|
||||
expect(avatarImg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Snack');
|
||||
// When expense_date is null, the fallback '—' is shown
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,419 +1,12 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||
import { Plus, Calculator, Download } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { CURRENCIES, SYMBOLS } from './BudgetPanel.constants'
|
||||
import { useBudgetPanel } from './useBudgetPanel'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import BudgetCategoryTable from './BudgetPanelCategoryTable'
|
||||
import BudgetSummary from './BudgetPanelSummary'
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
const SYMBOLS = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
const fmtNum = (v, locale, cur) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
||||
const calcPD = (p, d) => (d > 0 ? p / d : null)
|
||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||
|
||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value ?? '')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
const display = type === 'number' && value != null
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
|
||||
return (
|
||||
<tr style={{ background: 'var(--bg-secondary)' }}>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={t('budget.newEntry')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-Person Inline (inside total card) ────────────────────────────────────
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map(person => (
|
||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{person.avatar_url
|
||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: person.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
if (total === 0) return null
|
||||
|
||||
let cumDeg = 0
|
||||
const stops = segments.map(seg => {
|
||||
const start = cumDeg
|
||||
const deg = (seg.value / total) * 360
|
||||
cumDeg += deg
|
||||
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||
}).join(', ')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: size * 0.55, height: size * 0.55,
|
||||
borderRadius: '50%', background: 'var(--bg-card)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { splitColorFor } from './BudgetPanel.helpers'
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
interface BudgetPanelProps {
|
||||
@@ -422,104 +15,21 @@ interface BudgetPanelProps {
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
||||
const cat = item.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
return acc
|
||||
}, {}), [budgetItems])
|
||||
|
||||
const categoryNames = Object.keys(grouped)
|
||||
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||
|
||||
const pieSegments = useMemo(() =>
|
||||
categoryNames.map((cat, i) => ({
|
||||
name: cat,
|
||||
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
|
||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
|
||||
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
|
||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
||||
const handleDeleteCategory = async (cat) => {
|
||||
const items = grouped[cat] || []
|
||||
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped[cat] || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
const {
|
||||
budgetItems,
|
||||
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||
t, locale, isDark, theme,
|
||||
newCategoryName, setNewCategoryName,
|
||||
editingCat, setEditingCat,
|
||||
settlement, settlementOpen, setSettlementOpen,
|
||||
currency, canEdit, fmt, hasMultipleMembers,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||
setCurrency,
|
||||
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||
th, td,
|
||||
} = useBudgetPanel(tripId, tripMembers)
|
||||
|
||||
// ── Empty State ──────────────────────────────────────────────────────────
|
||||
if (!budgetItems || budgetItems.length === 0) {
|
||||
@@ -548,321 +58,88 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
<div>
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
title={t('budget.addCategory')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped[cat]
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = PIE_COLORS[ci % PIE_COLORS.length]
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
const hasMembers = item.members?.length > 0
|
||||
return (
|
||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{categoryNames.map(cat => (
|
||||
<BudgetCategoryTable key={cat} cat={cat} grouped={grouped} categoryColor={categoryColor}
|
||||
canEdit={canEdit} editingCat={editingCat} setEditingCat={setEditingCat}
|
||||
dragCat={dragCat} setDragCat={setDragCat} dragOverCat={dragOverCat} setDragOverCat={setDragOverCat}
|
||||
dragItem={dragItem} setDragItem={setDragItem} dragOverItem={dragOverItem} setDragOverItem={setDragOverItem}
|
||||
dragItemCat={dragItemCat} setDragItemCat={setDragItemCat}
|
||||
categoryNames={categoryNames} reorderBudgetCategories={reorderBudgetCategories} reorderBudgetItems={reorderBudgetItems}
|
||||
handleRenameCategory={handleRenameCategory} handleDeleteCategory={handleDeleteCategory} handleDeleteItem={handleDeleteItem}
|
||||
handleUpdateField={handleUpdateField} handleAddItem={handleAddItem}
|
||||
tripId={tripId} currency={currency} locale={locale} t={t} fmt={fmt}
|
||||
hasMultipleMembers={hasMultipleMembers} tripMembers={tripMembers}
|
||||
setBudgetItemMembers={setBudgetItemMembers} toggleBudgetMemberPaid={toggleBudgetMemberPaid}
|
||||
th={th} td={td} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Wallet size={18} color="rgba(255,255,255,0.8)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
</div>
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||
}}>
|
||||
{b.avatar_url
|
||||
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: b.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<BudgetSummary theme={theme} currency={currency} locale={locale} grandTotal={grandTotal}
|
||||
hasMultipleMembers={hasMultipleMembers} budgetItems={budgetItems} settlement={settlement}
|
||||
settlementOpen={settlementOpen} setSettlementOpen={setSettlementOpen} pieSegments={pieSegments}
|
||||
isDark={isDark} tripId={tripId} t={t} fmt={fmt} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
|
||||
return (
|
||||
<tr className="bg-surface-secondary">
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={t('budget.newEntry')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||
import AddItemRow from './BudgetPanelAddItemRow'
|
||||
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||
import type { EditingCat, AddItemData } from './useBudgetPanel'
|
||||
|
||||
interface BudgetCategoryTableProps {
|
||||
cat: string
|
||||
grouped: Map<string, BudgetItem[]>
|
||||
categoryColor: (cat: string) => string
|
||||
canEdit: boolean
|
||||
editingCat: EditingCat | null
|
||||
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
|
||||
dragCat: string | null
|
||||
setDragCat: Dispatch<SetStateAction<string | null>>
|
||||
dragOverCat: string | null
|
||||
setDragOverCat: Dispatch<SetStateAction<string | null>>
|
||||
dragItem: number | null
|
||||
setDragItem: Dispatch<SetStateAction<number | null>>
|
||||
dragOverItem: number | null
|
||||
setDragOverItem: Dispatch<SetStateAction<number | null>>
|
||||
dragItemCat: string | null
|
||||
setDragItemCat: Dispatch<SetStateAction<string | null>>
|
||||
categoryNames: string[]
|
||||
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
handleDeleteCategory: (cat: string) => Promise<void>
|
||||
handleDeleteItem: (id: number) => Promise<void>
|
||||
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
|
||||
handleAddItem: (category: string, data: AddItemData) => Promise<void>
|
||||
tripId: number
|
||||
currency: string
|
||||
locale: string
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
hasMultipleMembers: boolean
|
||||
tripMembers: TripMember[]
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
th: CSSProperties
|
||||
td: CSSProperties
|
||||
}
|
||||
|
||||
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
|
||||
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
|
||||
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||
const items = grouped.get(cat) || []
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = categoryColor(cat)
|
||||
return (
|
||||
<div key={cat} data-drag-cat={cat} style={{
|
||||
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onDragOver={e => {
|
||||
if (!dragCat || dragCat === cat || dragItem) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCat(cat)
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragCat && dragCat !== cat) {
|
||||
const newOrder = [...categoryNames]
|
||||
const fromIdx = newOrder.indexOf(dragCat)
|
||||
const toIdx = newOrder.indexOf(cat)
|
||||
newOrder.splice(fromIdx, 1)
|
||||
newOrder.splice(toIdx, 0, dragCat)
|
||||
reorderBudgetCategories(tripId, newOrder)
|
||||
}
|
||||
setDragCat(null); setDragOverCat(null)
|
||||
}}
|
||||
>
|
||||
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
|
||||
borderRadius: '10px 10px 0 0', padding: '9px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
|
||||
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
|
||||
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
const hasMembers = (item.members?.length ?? 0) > 0
|
||||
return (
|
||||
<tr key={item.id}
|
||||
style={{
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
opacity: dragItem === item.id ? 0.4 : 1,
|
||||
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
|
||||
}}
|
||||
onDragOver={e => {
|
||||
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
|
||||
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
|
||||
}}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
|
||||
onDrop={e => {
|
||||
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const ids = items.map(i => i.id)
|
||||
const fromIdx = ids.indexOf(dragItem)
|
||||
const toIdx = ids.indexOf(item.id)
|
||||
ids.splice(fromIdx, 1)
|
||||
ids.splice(toIdx, 0, dragItem)
|
||||
reorderBudgetItems(tripId, ids)
|
||||
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineEditCellProps {
|
||||
value: string | number | null | undefined
|
||||
onSave: (value: string | number | null) => void
|
||||
type?: 'text' | 'number'
|
||||
style?: React.CSSProperties
|
||||
placeholder?: string
|
||||
decimals?: number
|
||||
locale: string
|
||||
editTooltip?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState<string | number>(value ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v: string | number | null = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// Strip everything except digits, dots, commas, minus
|
||||
text = text.replace(/[^\d.,-]/g, '')
|
||||
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
const decimalPos = Math.max(lastComma, lastDot)
|
||||
if (decimalPos > -1) {
|
||||
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||
const decPart = text.substring(decimalPos + 1)
|
||||
text = intPart + '.' + decPart
|
||||
} else {
|
||||
text = text.replace(/[.,]/g, '')
|
||||
}
|
||||
setEditValue(text)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
const display = type === 'number' && value != null
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Pencil, Users, Check } from 'lucide-react'
|
||||
import type { BudgetItemMember } from '../../types'
|
||||
|
||||
export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetItemMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId: number) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
|
||||
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
{grandTotal > 0 && (
|
||||
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||
background: p.color.gradient,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{people.map(p => {
|
||||
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||
return (
|
||||
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Wallet } from 'lucide-react'
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
if (total === 0) return null
|
||||
|
||||
let cumDeg = 0
|
||||
const stops = segments.map(seg => {
|
||||
const start = cumDeg
|
||||
const deg = (seg.value / total) * 360
|
||||
cumDeg += deg
|
||||
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||
}).join(', ')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div
|
||||
className="trek-pie-reveal"
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: size * 0.55, height: size * 0.55,
|
||||
borderRadius: '50%', background: 'var(--bg-card)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { colorForUserId } from './BudgetPanel.helpers'
|
||||
|
||||
export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||
const color = colorForUserId(userId)
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
padding: 2, background: color.gradient,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', borderRadius: '50%',
|
||||
background: innerBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS } from './BudgetPanel.constants'
|
||||
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
import PerPersonInline from './BudgetPanelPerPersonInline'
|
||||
import type { SettlementData, PieSegment } from './useBudgetPanel'
|
||||
|
||||
interface BudgetSummaryProps {
|
||||
theme: ReturnType<typeof widgetTheme>
|
||||
currency: string
|
||||
locale: string
|
||||
grandTotal: number
|
||||
hasMultipleMembers: boolean
|
||||
budgetItems: BudgetItem[]
|
||||
settlement: SettlementData | null
|
||||
settlementOpen: boolean
|
||||
setSettlementOpen: Dispatch<SetStateAction<boolean>>
|
||||
pieSegments: PieSegment[]
|
||||
isDark: boolean
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
}
|
||||
|
||||
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
|
||||
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
|
||||
return (
|
||||
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 14,
|
||||
background: theme.flowBg,
|
||||
border: `1px solid ${theme.flowBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||
>
|
||||
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||
</div>
|
||||
</div>
|
||||
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||
const positive = b.balance > 0
|
||||
const Trend = positive ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||
color: positive ? '#10b981' : '#ef4444',
|
||||
}}>
|
||||
<Trend size={11} strokeWidth={3} />
|
||||
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||
const R = 80
|
||||
const CIRC = 2 * Math.PI * R
|
||||
let dashOffset = 0
|
||||
return (
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 11,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||
<defs>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
return (
|
||||
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={seg.color} />
|
||||
<stop offset="100%" stopColor={c2} />
|
||||
</linearGradient>
|
||||
)
|
||||
})}
|
||||
</defs>
|
||||
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||
{pieSegments.map((seg, i) => {
|
||||
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||
const circle = (
|
||||
<circle key={i}
|
||||
cx={100} cy={100} r={R}
|
||||
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||
stroke={`url(#cat-grad-${i})`}
|
||||
strokeDasharray={`${segLen} ${CIRC}`}
|
||||
strokeDashoffset={-dashOffset}
|
||||
/>
|
||||
)
|
||||
dashOffset += segLen
|
||||
return circle
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span>{totalInt}</span>
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||
return (
|
||||
<div key={seg.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 8px', borderRadius: 12,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '4px 9px', borderRadius: 7,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
}}>{pctLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
interface CostsPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
interface Settlement {
|
||||
id: number
|
||||
from_user_id: number
|
||||
to_user_id: number
|
||||
amount: number
|
||||
created_at?: string
|
||||
from_username?: string
|
||||
to_username?: string
|
||||
}
|
||||
interface SettlementData {
|
||||
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
||||
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
||||
settlements: Settlement[]
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||
|
||||
export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) {
|
||||
const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore()
|
||||
const me = useAuthStore(s => s.user?.id ?? -1)
|
||||
const can = useCanDo()
|
||||
const canEdit = can('budget_edit', trip)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// Display/base currency = the user's preferred currency (Settings), falling back
|
||||
// to the trip's own currency. Everything in Costs is converted to and shown in it.
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [histOpen, setHistOpen] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
|
||||
const people = tripMembers
|
||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||
const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t])
|
||||
const colorFor = useCallback((id: number) => {
|
||||
const idx = people.findIndex(p => p.id === id)
|
||||
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
|
||||
}, [people])
|
||||
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t])
|
||||
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
|
||||
}, [tripId, base])
|
||||
|
||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
||||
|
||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'expense') {
|
||||
setEditing(null); setModalOpen(true)
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
const n = (e.members || []).length
|
||||
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
||||
return baseTotal(e) / n
|
||||
}
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
||||
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
return { totalSpend, myPaid, myShare, owe, owed }
|
||||
}, [budgetItems, settlement, me])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
let list = budgetItems.slice()
|
||||
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
|
||||
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [budgetItems, filter, search, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||
const labelOf = (e: BudgetItem) => {
|
||||
if (!e.expense_date) return t('costs.noDate')
|
||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||
}
|
||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const day = labelOf(e)
|
||||
let g = groups.find(x => x.day === day)
|
||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||
g.items.push(e)
|
||||
}
|
||||
return groups
|
||||
}, [filtered, locale, t])
|
||||
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
const undoSettlement = async (id: number) => {
|
||||
try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
const settleAll = async () => {
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const dateMeta = useMemo(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return null
|
||||
try {
|
||||
const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z')
|
||||
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1
|
||||
const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const
|
||||
return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days }
|
||||
} catch { return null }
|
||||
}, [trip?.start_date, trip?.end_date, locale])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// ── small presentational helpers ────────────────────────────────────────
|
||||
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
|
||||
const url = personById(id)?.avatar_url
|
||||
if (url) return <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block' }} />
|
||||
return <span style={{ width: size, height: size, borderRadius: '50%', background: colorFor(id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0 }}>{initial(id)}</span>
|
||||
}
|
||||
|
||||
const cardCls = 'bg-surface-card border border-edge'
|
||||
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
|
||||
|
||||
// Big money number with the design's muted symbol/decimals, locale-correct via Intl.
|
||||
const bigMoney = (amount: number, smallSize: number, mutedColor: string) => {
|
||||
let parts: Intl.NumberFormatPart[] | null = null
|
||||
try {
|
||||
const d = currencyDecimals(base)
|
||||
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||
} catch { return <>{formatMoney(amount, base, locale)}</> }
|
||||
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||
{isMobile ? <MobileBody /> : (
|
||||
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
||||
{/* ── Header bar ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{dateMeta && (
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
{people.slice(0, 4).map((p, i) => {
|
||||
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
|
||||
return p.avatar_url
|
||||
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
|
||||
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
||||
})}
|
||||
</span>
|
||||
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Check size={16} /> {t('costs.settleUp')}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={16} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
||||
icon={<ArrowDown size={18} />} tone="owe"
|
||||
foot={totals.owe > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
<SummaryCard label={t('costs.youreOwed')} sub={t('costs.youreOwedSub')} amount={totals.owed} currency={base} locale={locale}
|
||||
icon={<ArrowUp size={18} />} tone="owed"
|
||||
foot={totals.owed > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||
icon={<BarChart3 size={18} />} tone="total"
|
||||
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
||||
</div>
|
||||
|
||||
{/* ── Main grid ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }} className="costs-grid">
|
||||
{/* expenses */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
|
||||
{t('costs.expenses')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
|
||||
<Search size={15} className="text-content-faint" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
|
||||
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.filter.' + f)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayGroups.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* sidebar */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
|
||||
{/* balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
</div>
|
||||
|
||||
{/* by category */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{modalOpen && (
|
||||
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||
</Modal>
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
--c-surface: #ffffff; --c-surface2: oklch(0.985 0.006 78);
|
||||
--c-ink: oklch(0.22 0.012 65); --c-ink2: oklch(0.42 0.012 65); --c-ink3: oklch(0.62 0.01 65);
|
||||
--c-line: oklch(0.92 0.008 70);
|
||||
}
|
||||
html.dark .costs-root {
|
||||
--c-bg: #121215; --c-bg2: #18181c;
|
||||
--c-surface: #1a1a1e; --c-surface2: #202027;
|
||||
--c-ink: #f4f4f5; --c-ink2: #a1a1aa; --c-ink3: #71717a;
|
||||
--c-line: #2a2a31;
|
||||
}
|
||||
.costs-root .bg-surface-card { background: var(--c-surface) !important; }
|
||||
.costs-root .bg-surface-secondary, .costs-root .bg-surface-input { background: var(--c-surface2) !important; }
|
||||
.costs-root .border-edge { border-color: var(--c-line) !important; }
|
||||
/* dark = neutral zinc + a touch of liquid glass, matching the dashboard */
|
||||
html.dark .costs-root .bg-surface-card {
|
||||
background: rgba(255,255,255,0.035) !important;
|
||||
border-color: rgba(255,255,255,0.08) !important;
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
}
|
||||
html.dark .costs-root .bg-surface-secondary,
|
||||
html.dark .costs-root .bg-surface-input { background: rgba(255,255,255,0.05) !important; }
|
||||
html.dark .costs-root .border-edge { border-color: rgba(255,255,255,0.08) !important; }
|
||||
.costs-root .text-content { color: var(--c-ink) !important; }
|
||||
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
|
||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
||||
.costs-root .exp-actions { opacity: 1; }
|
||||
@media (max-width: 1100px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── shared settle-flow list ──────────────────────────────────────────────
|
||||
function SettleFlows() {
|
||||
const flows = settlement?.flows || []
|
||||
if (flows.length === 0) return (
|
||||
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
|
||||
<div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{flows.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${personName(f.from.user_id)} → ${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
|
||||
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
|
||||
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
|
||||
function MobileBody() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||
{/* Total card */}
|
||||
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
|
||||
<div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
|
||||
<div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
|
||||
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
|
||||
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
|
||||
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={17} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Owe / Owed */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
|
||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
|
||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
|
||||
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
|
||||
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
|
||||
{/* Expenses */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
|
||||
<Search size={16} className="text-content-faint" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||
))}
|
||||
</div>
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: dayGroups.map(g => {
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
</div>
|
||||
|
||||
{/* By category */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── inline subcomponents (close over helpers) ────────────────────────────
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||
{payers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
{payers.map(p => (
|
||||
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 11.5 }}>
|
||||
<Avatar id={p.user_id} size={18} />
|
||||
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{rows.map(r => {
|
||||
const pct = Math.min(100, Math.abs(r.balance) / max * 100)
|
||||
const pos = r.balance > 0.01, neg = r.balance < -0.01
|
||||
return (
|
||||
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<Avatar id={r.user_id} size={28} />
|
||||
<div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
|
||||
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
|
||||
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
|
||||
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
|
||||
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
const tot: Record<string, number> = {}
|
||||
let grand = 0
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
|
||||
<span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
|
||||
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
|
||||
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure subcomponents ─────────────────────────────────────────────────────
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
||||
const total = tone === 'total'
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
||||
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
||||
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
||||
// while letting Intl place the symbol and pick separators per locale + currency.
|
||||
let parts: Intl.NumberFormatPart[] | null = null
|
||||
try {
|
||||
const d = currencyDecimals(currency)
|
||||
parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||
} catch { parts = null }
|
||||
const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||
return (
|
||||
<div className={total ? '' : 'bg-surface-card border border-edge'}
|
||||
style={{ borderRadius: 22, padding: '26px 28px', position: 'relative', overflow: 'hidden', ...(total ? { background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff' } : {}) }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
|
||||
<div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
|
||||
{parts
|
||||
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
|
||||
: <span>{formatMoney(amount, currency, locale)}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) {
|
||||
const uniq = Array.from(new Set(ids))
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content-faint">{lead}</span>
|
||||
{uniq.map(id => (
|
||||
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
|
||||
<Avatar id={id} size={18} />{name(id)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{settlements.map(s => (
|
||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
|
||||
const [name, setName] = useState(editing?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
const [split, setSplit] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||
const data = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
currency,
|
||||
payers: payerList, member_ids: [...split],
|
||||
expense_date: day || null,
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
onSaved()
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whatFor')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.currency')}</label>
|
||||
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.day')}</label>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && payersTotal > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.category')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
const Icon = c.Icon; const on = cat === c.key
|
||||
return (
|
||||
<button key={c.key} onClick={() => setCat(c.key)}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
|
||||
{t(c.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{people.map(p => {
|
||||
const on = split.has(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
* one of these. Category colour is the one place an accent is allowed (it
|
||||
* visualises the category); everything else stays black/white. The label comes
|
||||
* from i18n (`costs.cat.*`).
|
||||
*/
|
||||
export interface CostCategoryMeta {
|
||||
key: CostCategory
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
}
|
||||
|
||||
export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' },
|
||||
food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' },
|
||||
groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' },
|
||||
transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' },
|
||||
flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' },
|
||||
activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' },
|
||||
sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' },
|
||||
shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' },
|
||||
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
||||
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
||||
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
||||
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
||||
}
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
return dark
|
||||
}
|
||||
|
||||
export interface EditingCat {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SettlementPerson {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface SettlementFlow {
|
||||
from: SettlementPerson
|
||||
to: SettlementPerson
|
||||
amount: number
|
||||
}
|
||||
|
||||
interface SettlementBalance {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface SettlementData {
|
||||
balances: SettlementBalance[]
|
||||
flows: SettlementFlow[]
|
||||
}
|
||||
|
||||
export interface PieSegment {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface AddItemData {
|
||||
name: string
|
||||
total_price: number
|
||||
persons: number | null
|
||||
days: number | null
|
||||
note: string | null
|
||||
expense_date: string | null
|
||||
}
|
||||
|
||||
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Drag state for categories
|
||||
const [dragCat, setDragCat] = useState<string | null>(null)
|
||||
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
|
||||
// Drag state for items within a category
|
||||
const [dragItem, setDragItem] = useState<number | null>(null)
|
||||
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
|
||||
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur: string) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, BudgetItem[]>()
|
||||
for (const item of (budgetItems || [])) {
|
||||
const cat = item.category || 'Other'
|
||||
if (!map.has(cat)) map.set(cat, [])
|
||||
map.get(cat)!.push(item)
|
||||
}
|
||||
return map
|
||||
}, [budgetItems])
|
||||
|
||||
const categoryNames = Array.from(grouped.keys())
|
||||
|
||||
// Stable color mapping: assign index-based colors once, never reassign on reorder
|
||||
const colorMapRef = useRef(new Map<string, string>())
|
||||
const categoryColor = useCallback((cat: string) => {
|
||||
const map = colorMapRef.current
|
||||
if (!map.has(cat)) {
|
||||
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
|
||||
}
|
||||
return map.get(cat)!
|
||||
}, [])
|
||||
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||
|
||||
const pieSegments = useMemo<PieSegment[]>(() =>
|
||||
categoryNames.map((cat, i) => ({
|
||||
name: cat,
|
||||
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
|
||||
color: categoryColor(cat),
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
|
||||
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteCategory = async (cat: string) => {
|
||||
const items = grouped.get(cat) || []
|
||||
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped.get(oldName) || []
|
||||
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped.get(cat) || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = ''
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
return {
|
||||
trip, budgetItems,
|
||||
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||
t, locale, isDark, theme,
|
||||
newCategoryName, setNewCategoryName,
|
||||
editingCat, setEditingCat,
|
||||
settlement, settlementOpen, setSettlementOpen,
|
||||
currency, canEdit, fmt, hasMultipleMembers,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||
setCurrency,
|
||||
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||
th, td,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// Reaction Quick Menu (right-click)
|
||||
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
@@ -0,0 +1,42 @@
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
export function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
export function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
export function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
// FE-COMP-CHAT-001 to FE-COMP-CHAT-012
|
||||
// jsdom doesn't implement scrollTo — mock it to prevent uncaught exceptions from CollabChat's scrollToBottom
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollTo = vi.fn() as any;
|
||||
});
|
||||
|
||||
// CollabChat uses addListener/removeListener from websocket — extend the global mock
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabChat from './CollabChat';
|
||||
import { addListener } from '../../api/websocket';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
currentUser,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({ messages: [], total: 0 })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabChat', () => {
|
||||
it('FE-COMP-CHAT-001: renders without crashing', () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
// Wait for loading to complete
|
||||
await screen.findByText('Start the conversation');
|
||||
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-004: shows send button (ArrowUp icon, no title)', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
// Send button has no title attr — verify buttons exist in the toolbar area
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-005: shows existing messages from API', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
|
||||
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
|
||||
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Hello world!');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const input = screen.getByPlaceholderText('Type a message...');
|
||||
await user.type(input, 'Test message');
|
||||
expect((input as HTMLTextAreaElement).value).toBe('Test message');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-007: submitting message via Enter calls POST API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/messages', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({
|
||||
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
|
||||
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const input = screen.getByPlaceholderText('Type a message...');
|
||||
// Enter key sends message (Shift+Enter = newline, Enter = send)
|
||||
await user.type(input, 'New message{Enter}');
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-008: message input area is present after loading', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText(/Share ideas, plans/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-010: chat container renders', () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
expect(document.body.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-011: multiple messages all render', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [
|
||||
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('First message');
|
||||
expect(screen.getByText('Second message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-012: shows emoji button in the toolbar', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
// Emoji button is a button in the toolbar
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-013: date separator shows "Today" for messages sent today', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Hello world!', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Hello world!');
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-014: Shift+Enter inserts a newline instead of sending', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/messages', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const input = screen.getByPlaceholderText('Type a message...');
|
||||
await user.click(input);
|
||||
await user.type(input, 'Line1');
|
||||
await user.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await user.type(input, 'Line2');
|
||||
expect((input as HTMLTextAreaElement).value).toContain('\n');
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-015: deleted message shows fallback text', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'some text', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: true, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-017: reaction badge renders for a message with reactions', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'React to me', created_at: new Date().toISOString(),
|
||||
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||
reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('React to me');
|
||||
// ReactionBadge renders a button containing a TwemojiImg with alt=emoji
|
||||
const img = screen.getByAltText('❤️');
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-018: WebSocket collab:message:created event adds message to list', async () => {
|
||||
vi.clearAllMocks();
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||
const handler = (addListener as any).mock.calls[0][0];
|
||||
await act(async () => {
|
||||
handler({
|
||||
type: 'collab:message:created',
|
||||
tripId: 1,
|
||||
message: {
|
||||
id: 99, trip_id: 1, user_id: 2, username: 'alice',
|
||||
text: 'WS message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('WS message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-019: WebSocket collab:message:deleted event marks message as deleted', async () => {
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'To remove', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('To remove');
|
||||
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||
const handler = (addListener as any).mock.calls[0][0];
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('To remove')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-020: send button is disabled when input is empty', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The send button is the ArrowUp button — it has disabled attr when text is empty
|
||||
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
|
||||
expect(sendButton).toBeTruthy();
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-021: reply-to banner shows quoted author and text', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reply here', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null,
|
||||
reply_text: 'Original message', reply_username: 'alice',
|
||||
deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Reply here');
|
||||
expect(screen.getByText(/Original message/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-022: own messages are displayed with blue bubble', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||
text: 'My own message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('My own message');
|
||||
// Own messages don't show a username label above the bubble (only other users get it)
|
||||
// The component renders {!own && isNewGroup && <span>{msg.username}</span>}
|
||||
// so 'testuser' should NOT appear as a username label
|
||||
const usernameLabels = screen.queryAllByText('testuser');
|
||||
expect(usernameLabels.length).toBe(0);
|
||||
// And own message bubble uses row-reverse flex direction
|
||||
const messageEl = screen.getByText('My own message');
|
||||
let parent = messageEl.parentElement;
|
||||
let foundRowReverse = false;
|
||||
while (parent) {
|
||||
const styleAttr = parent.getAttribute('style');
|
||||
if (styleAttr && styleAttr.includes('row-reverse')) {
|
||||
foundRowReverse = true;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
expect(foundRowReverse).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-023: sending a message clears the input field', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/messages', async () =>
|
||||
HttpResponse.json({
|
||||
message: {
|
||||
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const input = screen.getByPlaceholderText('Type a message...');
|
||||
await user.type(input, 'Sent message');
|
||||
expect((input as HTMLTextAreaElement).value).toBe('Sent message');
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => {
|
||||
expect((input as HTMLTextAreaElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
|
||||
const messages = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({ messages, total: 100 })
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Message 1');
|
||||
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
|
||||
expect(loadMoreBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-025: clicking reply button on a message sets reply-to preview', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reply to me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Reply to me');
|
||||
// Hover action buttons are always in DOM but hidden via pointer-events: none
|
||||
// Use fireEvent to bypass CSS pointer-events restrictions
|
||||
const replyBtn = screen.getByTitle('Reply');
|
||||
fireEvent.click(replyBtn);
|
||||
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
|
||||
await waitFor(() => {
|
||||
const aliceEls = screen.queryAllByText('alice');
|
||||
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-026: clicking X in reply preview cancels reply', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Cancel reply test', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Cancel reply test');
|
||||
// Click reply button to show preview (bypassing pointer-events: none)
|
||||
fireEvent.click(screen.getByTitle('Reply'));
|
||||
// Wait for reply preview <strong> to appear
|
||||
await waitFor(() => {
|
||||
const aliceEls = screen.queryAllByText('alice');
|
||||
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||
});
|
||||
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
|
||||
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
|
||||
const previewDiv = strongEl.closest('div[style]');
|
||||
const xBtn = previewDiv?.querySelector('button');
|
||||
expect(xBtn).toBeTruthy();
|
||||
fireEvent.click(xBtn!);
|
||||
await waitFor(() => {
|
||||
// After cancel, no <strong>alice</strong> in reply preview
|
||||
const remaining = screen.queryAllByText('alice');
|
||||
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-027: clicking emoji button opens the emoji picker', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
// Smile button is the only non-disabled button when input is empty
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||
expect(smileBtn).toBeTruthy();
|
||||
await user.click(smileBtn!);
|
||||
// EmojiPicker renders category tabs
|
||||
await screen.findByText('Smileys');
|
||||
expect(screen.getByText('Reactions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-028: selecting emoji from picker appends it to the input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||
await user.click(smileBtn!);
|
||||
// Wait for picker to open
|
||||
await screen.findByText('Smileys');
|
||||
// Click the first emoji in the grid (😀 is the first in Smileys)
|
||||
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
|
||||
expect(emojiImg).toBeTruthy();
|
||||
await user.click(emojiImg!.closest('button')!);
|
||||
// Emoji should be appended to textarea
|
||||
const textarea = screen.getByPlaceholderText('Type a message...');
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain('😀');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-029: right-clicking a message opens the reaction menu', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Right click me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Right click me');
|
||||
const messageBubble = screen.getByText('Right click me').closest('div[style]');
|
||||
fireEvent.contextMenu(messageBubble!);
|
||||
// ReactionMenu renders quick reactions (❤️ is the first)
|
||||
await waitFor(() => {
|
||||
const reactionImgs = screen.getAllByRole('img').filter(img =>
|
||||
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
|
||||
);
|
||||
expect(reactionImgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-030: clicking a reaction in the menu calls reactMessage API', async () => {
|
||||
let reactCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'React to this', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
http.post('/api/trips/1/collab/messages/1/react', async () => {
|
||||
reactCalled = true;
|
||||
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
|
||||
})
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('React to this');
|
||||
// Open reaction context menu
|
||||
const messageBubble = screen.getByText('React to this').closest('div[style]');
|
||||
fireEvent.contextMenu(messageBubble!);
|
||||
// Wait for menu and click first reaction (❤️)
|
||||
const heartImg = await screen.findByAltText('❤️');
|
||||
fireEvent.click(heartImg.closest('button')!);
|
||||
await waitFor(() => expect(reactCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-031: WebSocket collab:message:reacted event updates reactions', async () => {
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Reacted message', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Reacted message');
|
||||
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||
const handler = (addListener as any).mock.calls[0][0];
|
||||
await act(async () => {
|
||||
handler({
|
||||
type: 'collab:message:reacted',
|
||||
tripId: 1,
|
||||
messageId: 1,
|
||||
reactions: [{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||
});
|
||||
});
|
||||
await screen.findByAltText('🔥');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
|
||||
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `New ${i + 100}`, created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}));
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ messages: initialMessages, total: 120 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 120,
|
||||
});
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('New 100');
|
||||
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
|
||||
await user.click(loadMoreBtn);
|
||||
await screen.findByText('Older message');
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||
text: 'Delete me', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
http.delete('/api/trips/1/collab/messages/1', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Delete me');
|
||||
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
|
||||
const deleteBtn = screen.getByTitle('Delete');
|
||||
fireEvent.click(deleteBtn);
|
||||
// handleDelete uses a 400ms setTimeout before calling the API
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: '👍', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('👍');
|
||||
// Big emoji renders in a div with fontSize: 40px — include emojiEl itself in search
|
||||
const emojiEl = screen.getByText('👍');
|
||||
let el: HTMLElement | null = emojiEl as HTMLElement;
|
||||
let foundBigEmoji = false;
|
||||
while (el) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr && styleAttr.includes('font-size: 40px')) {
|
||||
foundBigEmoji = true;
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
expect(foundBigEmoji).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-035: 24h time format renders timestamp without AM/PM', async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } as any });
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: 'Time format test', created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Time format test');
|
||||
// 24h format: timestamp like "HH:MM" — no AM/PM suffix
|
||||
expect(screen.queryByText(/AM|PM/)).not.toBeInTheDocument();
|
||||
// There should be a timestamp element matching HH:MM
|
||||
const timestamp = screen.getByText((text) => /^\d{1,2}:\d{2}$/.test(text));
|
||||
expect(timestamp).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-036: message with URL shows link preview when API returns data', async () => {
|
||||
const uniqueUrl = 'https://preview-test-unique-url-9999.example.com/page';
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/messages', () =>
|
||||
HttpResponse.json({
|
||||
messages: [{
|
||||
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||
text: `Check this out ${uniqueUrl}`,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/collab/link-preview', () =>
|
||||
HttpResponse.json({ title: 'Preview Title', description: 'Preview Desc', image: null, site_name: 'Example' })
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText(/Check this out/);
|
||||
await waitFor(
|
||||
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,350 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
function MessageText({ text }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
interface ReactionBadgeProps {
|
||||
reaction: ChatReaction
|
||||
currentUserId: number
|
||||
onReact: () => void
|
||||
}
|
||||
|
||||
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
const names = reaction.users.map(u => u.username).join(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
import { EmojiPicker } from './CollabChatEmojiPicker'
|
||||
import { ReactionMenu } from './CollabChatReactionMenu'
|
||||
|
||||
/* ── Main Component ── */
|
||||
interface CollabChatProps {
|
||||
@@ -353,167 +13,8 @@ interface CollabChatProps {
|
||||
}
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
||||
}, [])
|
||||
|
||||
const checkAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
}, [])
|
||||
|
||||
/* ── load messages ── */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getMessages(tripId).then(data => {
|
||||
if (cancelled) return
|
||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
setMessages(msgs)
|
||||
setHasMore(msgs.length >= 100)
|
||||
setLoading(false)
|
||||
setTimeout(() => scrollToBottom(), 30)
|
||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loadingMore || messages.length === 0) return
|
||||
setLoadingMore(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el ? el.scrollHeight : 0
|
||||
try {
|
||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
if (older.length === 0) { setHasMore(false) }
|
||||
else {
|
||||
setMessages(prev => [...older, ...prev])
|
||||
setHasMore(older.length >= 100)
|
||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
||||
}
|
||||
} catch {} finally { setLoadingMore(false) }
|
||||
}, [tripId, loadingMore, messages])
|
||||
|
||||
/* ── websocket ── */
|
||||
useEffect(() => {
|
||||
const handler = (event) => {
|
||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
||||
}
|
||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
}
|
||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
setText(e.target.value)
|
||||
const ta = textareaRef.current
|
||||
if (ta) {
|
||||
ta.style.height = 'auto'
|
||||
const h = Math.min(ta.scrollHeight, 100)
|
||||
ta.style.height = h + 'px'
|
||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch {} finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}, [handleSend])
|
||||
|
||||
const handleDelete = useCallback(async (msgId) => {
|
||||
const msg = messages.find(m => m.id === msgId)
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
setReactMenu(null)
|
||||
try {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
||||
|
||||
// Check if message is only emoji (1-3 emojis, no other text)
|
||||
const isEmojiOnly = (text) => {
|
||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
/* ── Loading ── */
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -522,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const own = isOwn(msg)
|
||||
const prevMsg = messages[idx - 1]
|
||||
const nextMsg = messages[idx + 1]
|
||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||
const showAvatar = !own && isLastInGroup
|
||||
const bigEmoji = isEmojiOnly(msg.text)
|
||||
const hasReply = msg.reply_text || msg.reply_to
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
e.currentTarget.dataset.lastTap = now
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { EMOJI_CATEGORIES } from './CollabChat.constants'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.currentTarget.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function MessageText({ text }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
|
||||
export function ChatMessages(props: any) {
|
||||
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||
return (
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const own = isOwn(msg)
|
||||
const prevMsg = messages[idx - 1]
|
||||
const nextMsg = messages[idx + 1]
|
||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||
const showAvatar = !own && isLastInGroup
|
||||
const bigEmoji = isEmojiOnly(msg.text)
|
||||
const hasReply = msg.reply_text || msg.reply_to
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = Number(e.currentTarget.dataset.lastTap) || 0
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
e.currentTarget.dataset.lastTap = String(now)
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user