@open-mercato/core 0.4.7-develop-0a657b411f → 0.4.7-develop-e249d3e7d0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/dist/generated/entities/carrier_shipment/index.js +37 -0
  2. package/dist/generated/entities/carrier_shipment/index.js.map +7 -0
  3. package/dist/generated/entities/gateway_transaction/index.js +47 -0
  4. package/dist/generated/entities/gateway_transaction/index.js.map +7 -0
  5. package/dist/generated/entities/webhook_processed_event/index.js +17 -0
  6. package/dist/generated/entities/webhook_processed_event/index.js.map +7 -0
  7. package/dist/generated/entities.ids.generated.js +10 -1
  8. package/dist/generated/entities.ids.generated.js.map +2 -2
  9. package/dist/generated/entity-fields-registry.js +6 -0
  10. package/dist/generated/entity-fields-registry.js.map +2 -2
  11. package/dist/modules/data_sync/api/runs/[id]/cancel.js +14 -5
  12. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  13. package/dist/modules/data_sync/backend/data-sync/page.meta.js +2 -2
  14. package/dist/modules/data_sync/backend/data-sync/page.meta.js.map +1 -1
  15. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +37 -12
  16. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  17. package/dist/modules/directory/api/get/tenants/lookup.js +1 -0
  18. package/dist/modules/directory/api/get/tenants/lookup.js.map +2 -2
  19. package/dist/modules/integrations/api/[id]/route.js +38 -11
  20. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  21. package/dist/modules/integrations/api/logs/route.js +52 -26
  22. package/dist/modules/integrations/api/logs/route.js.map +2 -2
  23. package/dist/modules/integrations/api/route.js +37 -7
  24. package/dist/modules/integrations/api/route.js.map +2 -2
  25. package/dist/modules/integrations/api/umes-read.js +121 -0
  26. package/dist/modules/integrations/api/umes-read.js.map +7 -0
  27. package/dist/modules/integrations/backend/integrations/[id]/page.js +715 -183
  28. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  29. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +30 -9
  30. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  31. package/dist/modules/integrations/backend/integrations/detail-page-widgets.js +46 -0
  32. package/dist/modules/integrations/backend/integrations/detail-page-widgets.js.map +7 -0
  33. package/dist/modules/integrations/backend/integrations/page.js +78 -62
  34. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  35. package/dist/modules/integrations/backend/integrations/page.meta.js +2 -2
  36. package/dist/modules/integrations/backend/integrations/page.meta.js.map +1 -1
  37. package/dist/modules/integrations/setup.js +2 -2
  38. package/dist/modules/integrations/setup.js.map +2 -2
  39. package/dist/modules/payment_gateways/acl.js +12 -0
  40. package/dist/modules/payment_gateways/acl.js.map +7 -0
  41. package/dist/modules/payment_gateways/api/cancel/route.js +55 -0
  42. package/dist/modules/payment_gateways/api/cancel/route.js.map +7 -0
  43. package/dist/modules/payment_gateways/api/capture/route.js +55 -0
  44. package/dist/modules/payment_gateways/api/capture/route.js.map +7 -0
  45. package/dist/modules/payment_gateways/api/interceptors.js +24 -0
  46. package/dist/modules/payment_gateways/api/interceptors.js.map +7 -0
  47. package/dist/modules/payment_gateways/api/openapi.js +5 -0
  48. package/dist/modules/payment_gateways/api/openapi.js.map +7 -0
  49. package/dist/modules/payment_gateways/api/refund/route.js +56 -0
  50. package/dist/modules/payment_gateways/api/refund/route.js.map +7 -0
  51. package/dist/modules/payment_gateways/api/sessions/route.js +74 -0
  52. package/dist/modules/payment_gateways/api/sessions/route.js.map +7 -0
  53. package/dist/modules/payment_gateways/api/status/route.js +66 -0
  54. package/dist/modules/payment_gateways/api/status/route.js.map +7 -0
  55. package/dist/modules/payment_gateways/api/transactions/[id]/route.js +118 -0
  56. package/dist/modules/payment_gateways/api/transactions/[id]/route.js.map +7 -0
  57. package/dist/modules/payment_gateways/api/transactions/route.js +113 -0
  58. package/dist/modules/payment_gateways/api/transactions/route.js.map +7 -0
  59. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +136 -0
  60. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +7 -0
  61. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +496 -0
  62. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +7 -0
  63. package/dist/modules/payment_gateways/backend/payment-gateways/page.meta.js +23 -0
  64. package/dist/modules/payment_gateways/backend/payment-gateways/page.meta.js.map +7 -0
  65. package/dist/modules/payment_gateways/data/enrichers.js +5 -0
  66. package/dist/modules/payment_gateways/data/enrichers.js.map +7 -0
  67. package/dist/modules/payment_gateways/data/entities.js +131 -0
  68. package/dist/modules/payment_gateways/data/entities.js.map +7 -0
  69. package/dist/modules/payment_gateways/data/validators.js +57 -0
  70. package/dist/modules/payment_gateways/data/validators.js.map +7 -0
  71. package/dist/modules/payment_gateways/di.js +16 -0
  72. package/dist/modules/payment_gateways/di.js.map +7 -0
  73. package/dist/modules/payment_gateways/events.js +21 -0
  74. package/dist/modules/payment_gateways/events.js.map +7 -0
  75. package/dist/modules/payment_gateways/i18n/en.js +6 -0
  76. package/dist/modules/payment_gateways/i18n/en.js.map +7 -0
  77. package/dist/modules/payment_gateways/i18n/pl.js +6 -0
  78. package/dist/modules/payment_gateways/i18n/pl.js.map +7 -0
  79. package/dist/modules/payment_gateways/index.js +9 -0
  80. package/dist/modules/payment_gateways/index.js.map +7 -0
  81. package/dist/modules/payment_gateways/lib/gateway-service.js +378 -0
  82. package/dist/modules/payment_gateways/lib/gateway-service.js.map +7 -0
  83. package/dist/modules/payment_gateways/lib/queue.js +17 -0
  84. package/dist/modules/payment_gateways/lib/queue.js.map +7 -0
  85. package/dist/modules/payment_gateways/lib/status-machine.js +29 -0
  86. package/dist/modules/payment_gateways/lib/status-machine.js.map +7 -0
  87. package/dist/modules/payment_gateways/lib/webhook-processor.js +88 -0
  88. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +7 -0
  89. package/dist/modules/payment_gateways/lib/webhook-utils.js +42 -0
  90. package/dist/modules/payment_gateways/lib/webhook-utils.js.map +7 -0
  91. package/dist/modules/payment_gateways/migrations/Migration20260305122155.js +19 -0
  92. package/dist/modules/payment_gateways/migrations/Migration20260305122155.js.map +7 -0
  93. package/dist/modules/payment_gateways/setup.js +13 -0
  94. package/dist/modules/payment_gateways/setup.js.map +7 -0
  95. package/dist/modules/payment_gateways/widgets/injection-table.js +7 -0
  96. package/dist/modules/payment_gateways/widgets/injection-table.js.map +7 -0
  97. package/dist/modules/payment_gateways/workers/status-poller.js +44 -0
  98. package/dist/modules/payment_gateways/workers/status-poller.js.map +7 -0
  99. package/dist/modules/payment_gateways/workers/webhook-processor.js +20 -0
  100. package/dist/modules/payment_gateways/workers/webhook-processor.js.map +7 -0
  101. package/dist/modules/sales/data/enrichers.js +72 -0
  102. package/dist/modules/sales/data/enrichers.js.map +7 -0
  103. package/dist/modules/sales/lib/makeSalesLineRoute.js +3 -0
  104. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  105. package/dist/modules/sales/widgets/injection/payment-gateway-config-field/widget.js +29 -0
  106. package/dist/modules/sales/widgets/injection/payment-gateway-config-field/widget.js.map +7 -0
  107. package/dist/modules/sales/widgets/injection/payment-gateway-status-column/widget.js +23 -0
  108. package/dist/modules/sales/widgets/injection/payment-gateway-status-column/widget.js.map +7 -0
  109. package/dist/modules/sales/widgets/injection-table.js +13 -1
  110. package/dist/modules/sales/widgets/injection-table.js.map +2 -2
  111. package/dist/modules/shipping_carriers/acl.js +10 -0
  112. package/dist/modules/shipping_carriers/acl.js.map +7 -0
  113. package/dist/modules/shipping_carriers/api/cancel/route.js +55 -0
  114. package/dist/modules/shipping_carriers/api/cancel/route.js.map +7 -0
  115. package/dist/modules/shipping_carriers/api/interceptors.js +21 -0
  116. package/dist/modules/shipping_carriers/api/interceptors.js.map +7 -0
  117. package/dist/modules/shipping_carriers/api/openapi.js +5 -0
  118. package/dist/modules/shipping_carriers/api/openapi.js.map +7 -0
  119. package/dist/modules/shipping_carriers/api/rates/route.js +55 -0
  120. package/dist/modules/shipping_carriers/api/rates/route.js.map +7 -0
  121. package/dist/modules/shipping_carriers/api/shipments/route.js +61 -0
  122. package/dist/modules/shipping_carriers/api/shipments/route.js.map +7 -0
  123. package/dist/modules/shipping_carriers/api/tracking/route.js +58 -0
  124. package/dist/modules/shipping_carriers/api/tracking/route.js.map +7 -0
  125. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +119 -0
  126. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +7 -0
  127. package/dist/modules/shipping_carriers/data/enrichers.js +82 -0
  128. package/dist/modules/shipping_carriers/data/enrichers.js.map +7 -0
  129. package/dist/modules/shipping_carriers/data/entities.js +80 -0
  130. package/dist/modules/shipping_carriers/data/entities.js.map +7 -0
  131. package/dist/modules/shipping_carriers/data/validators.js +49 -0
  132. package/dist/modules/shipping_carriers/data/validators.js.map +7 -0
  133. package/dist/modules/shipping_carriers/di.js +15 -0
  134. package/dist/modules/shipping_carriers/di.js.map +7 -0
  135. package/dist/modules/shipping_carriers/events.js +19 -0
  136. package/dist/modules/shipping_carriers/events.js.map +7 -0
  137. package/dist/modules/shipping_carriers/i18n/en.js +11 -0
  138. package/dist/modules/shipping_carriers/i18n/en.js.map +7 -0
  139. package/dist/modules/shipping_carriers/i18n/pl.js +11 -0
  140. package/dist/modules/shipping_carriers/i18n/pl.js.map +7 -0
  141. package/dist/modules/shipping_carriers/index.js +9 -0
  142. package/dist/modules/shipping_carriers/index.js.map +7 -0
  143. package/dist/modules/shipping_carriers/lib/adapter-registry.js +29 -0
  144. package/dist/modules/shipping_carriers/lib/adapter-registry.js.map +7 -0
  145. package/dist/modules/shipping_carriers/lib/adapter.js +1 -0
  146. package/dist/modules/shipping_carriers/lib/adapter.js.map +7 -0
  147. package/dist/modules/shipping_carriers/lib/queue.js +17 -0
  148. package/dist/modules/shipping_carriers/lib/queue.js.map +7 -0
  149. package/dist/modules/shipping_carriers/lib/shipping-service.js +155 -0
  150. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +7 -0
  151. package/dist/modules/shipping_carriers/lib/status-sync.js +37 -0
  152. package/dist/modules/shipping_carriers/lib/status-sync.js.map +7 -0
  153. package/dist/modules/shipping_carriers/migrations/Migration20260305170000.js +16 -0
  154. package/dist/modules/shipping_carriers/migrations/Migration20260305170000.js.map +7 -0
  155. package/dist/modules/shipping_carriers/setup.js +13 -0
  156. package/dist/modules/shipping_carriers/setup.js.map +7 -0
  157. package/dist/modules/shipping_carriers/widgets/injection/create-shipment-button/widget.js +25 -0
  158. package/dist/modules/shipping_carriers/widgets/injection/create-shipment-button/widget.js.map +7 -0
  159. package/dist/modules/shipping_carriers/widgets/injection/tracking-column/widget.js +23 -0
  160. package/dist/modules/shipping_carriers/widgets/injection/tracking-column/widget.js.map +7 -0
  161. package/dist/modules/shipping_carriers/widgets/injection/tracking-status-badge/widget.js +40 -0
  162. package/dist/modules/shipping_carriers/widgets/injection/tracking-status-badge/widget.js.map +7 -0
  163. package/dist/modules/shipping_carriers/widgets/injection-table.js +24 -0
  164. package/dist/modules/shipping_carriers/widgets/injection-table.js.map +7 -0
  165. package/dist/modules/shipping_carriers/workers/status-poller.js +21 -0
  166. package/dist/modules/shipping_carriers/workers/status-poller.js.map +7 -0
  167. package/dist/modules/shipping_carriers/workers/webhook-processor.js +54 -0
  168. package/dist/modules/shipping_carriers/workers/webhook-processor.js.map +7 -0
  169. package/dist/modules/translations/api/get/locales.js +1 -0
  170. package/dist/modules/translations/api/get/locales.js.map +2 -2
  171. package/dist/modules/translations/api/put/locales.js +1 -0
  172. package/dist/modules/translations/api/put/locales.js.map +2 -2
  173. package/generated/entities/carrier_shipment/index.ts +17 -0
  174. package/generated/entities/gateway_transaction/index.ts +22 -0
  175. package/generated/entities/webhook_processed_event/index.ts +7 -0
  176. package/generated/entities.ids.generated.ts +10 -1
  177. package/generated/entity-fields-registry.ts +6 -0
  178. package/jest.config.cjs +1 -0
  179. package/package.json +5 -2
  180. package/src/modules/auth/i18n/de.json +1 -0
  181. package/src/modules/auth/i18n/en.json +1 -0
  182. package/src/modules/auth/i18n/es.json +1 -0
  183. package/src/modules/auth/i18n/pl.json +1 -0
  184. package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -5
  185. package/src/modules/data_sync/backend/data-sync/page.meta.ts +2 -2
  186. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +50 -12
  187. package/src/modules/directory/api/get/tenants/lookup.ts +1 -0
  188. package/src/modules/integrations/AGENTS.md +31 -0
  189. package/src/modules/integrations/api/[id]/route.ts +38 -11
  190. package/src/modules/integrations/api/logs/route.ts +53 -27
  191. package/src/modules/integrations/api/route.ts +31 -1
  192. package/src/modules/integrations/api/umes-read.ts +177 -0
  193. package/src/modules/integrations/backend/integrations/[id]/page.tsx +902 -202
  194. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +43 -9
  195. package/src/modules/integrations/backend/integrations/detail-page-widgets.ts +74 -0
  196. package/src/modules/integrations/backend/integrations/page.meta.ts +2 -2
  197. package/src/modules/integrations/backend/integrations/page.tsx +65 -54
  198. package/src/modules/integrations/i18n/de.json +15 -0
  199. package/src/modules/integrations/i18n/en.json +15 -0
  200. package/src/modules/integrations/i18n/es.json +15 -0
  201. package/src/modules/integrations/i18n/pl.json +15 -0
  202. package/src/modules/integrations/setup.ts +2 -2
  203. package/src/modules/payment_gateways/acl.ts +8 -0
  204. package/src/modules/payment_gateways/api/cancel/route.ts +56 -0
  205. package/src/modules/payment_gateways/api/capture/route.ts +56 -0
  206. package/src/modules/payment_gateways/api/interceptors.ts +22 -0
  207. package/src/modules/payment_gateways/api/openapi.ts +1 -0
  208. package/src/modules/payment_gateways/api/refund/route.ts +57 -0
  209. package/src/modules/payment_gateways/api/sessions/route.ts +76 -0
  210. package/src/modules/payment_gateways/api/status/route.ts +69 -0
  211. package/src/modules/payment_gateways/api/transactions/[id]/route.ts +123 -0
  212. package/src/modules/payment_gateways/api/transactions/route.ts +120 -0
  213. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +161 -0
  214. package/src/modules/payment_gateways/backend/payment-gateways/page.meta.ts +19 -0
  215. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +660 -0
  216. package/src/modules/payment_gateways/data/enrichers.ts +8 -0
  217. package/src/modules/payment_gateways/data/entities.ts +106 -0
  218. package/src/modules/payment_gateways/data/validators.ts +67 -0
  219. package/src/modules/payment_gateways/di.ts +26 -0
  220. package/src/modules/payment_gateways/events.ts +17 -0
  221. package/src/modules/payment_gateways/i18n/de.json +77 -0
  222. package/src/modules/payment_gateways/i18n/en.json +77 -0
  223. package/src/modules/payment_gateways/i18n/en.ts +4 -0
  224. package/src/modules/payment_gateways/i18n/es.json +77 -0
  225. package/src/modules/payment_gateways/i18n/pl.json +77 -0
  226. package/src/modules/payment_gateways/i18n/pl.ts +4 -0
  227. package/src/modules/payment_gateways/index.ts +5 -0
  228. package/src/modules/payment_gateways/lib/gateway-service.ts +486 -0
  229. package/src/modules/payment_gateways/lib/queue.ts +19 -0
  230. package/src/modules/payment_gateways/lib/status-machine.ts +28 -0
  231. package/src/modules/payment_gateways/lib/webhook-processor.ts +133 -0
  232. package/src/modules/payment_gateways/lib/webhook-utils.ts +52 -0
  233. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +373 -0
  234. package/src/modules/payment_gateways/migrations/Migration20260305122155.ts +20 -0
  235. package/src/modules/payment_gateways/setup.ts +11 -0
  236. package/src/modules/payment_gateways/widgets/injection-table.ts +9 -0
  237. package/src/modules/payment_gateways/workers/status-poller.ts +58 -0
  238. package/src/modules/payment_gateways/workers/webhook-processor.ts +30 -0
  239. package/src/modules/sales/data/enrichers.ts +120 -0
  240. package/src/modules/sales/lib/makeSalesLineRoute.ts +3 -0
  241. package/src/modules/sales/widgets/injection/payment-gateway-config-field/widget.ts +28 -0
  242. package/src/modules/sales/widgets/injection/payment-gateway-status-column/widget.ts +22 -0
  243. package/src/modules/sales/widgets/injection-table.ts +12 -0
  244. package/src/modules/shipping_carriers/acl.ts +6 -0
  245. package/src/modules/shipping_carriers/api/cancel/route.ts +53 -0
  246. package/src/modules/shipping_carriers/api/interceptors.ts +19 -0
  247. package/src/modules/shipping_carriers/api/openapi.ts +1 -0
  248. package/src/modules/shipping_carriers/api/rates/route.ts +53 -0
  249. package/src/modules/shipping_carriers/api/shipments/route.ts +59 -0
  250. package/src/modules/shipping_carriers/api/tracking/route.ts +56 -0
  251. package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +134 -0
  252. package/src/modules/shipping_carriers/data/enrichers.ts +89 -0
  253. package/src/modules/shipping_carriers/data/entities.ts +60 -0
  254. package/src/modules/shipping_carriers/data/validators.ts +48 -0
  255. package/src/modules/shipping_carriers/di.ts +20 -0
  256. package/src/modules/shipping_carriers/events.ts +16 -0
  257. package/src/modules/shipping_carriers/i18n/de.json +7 -0
  258. package/src/modules/shipping_carriers/i18n/en.json +7 -0
  259. package/src/modules/shipping_carriers/i18n/en.ts +7 -0
  260. package/src/modules/shipping_carriers/i18n/es.json +7 -0
  261. package/src/modules/shipping_carriers/i18n/pl.json +7 -0
  262. package/src/modules/shipping_carriers/i18n/pl.ts +7 -0
  263. package/src/modules/shipping_carriers/index.ts +5 -0
  264. package/src/modules/shipping_carriers/lib/adapter-registry.ts +33 -0
  265. package/src/modules/shipping_carriers/lib/adapter.ts +93 -0
  266. package/src/modules/shipping_carriers/lib/queue.ts +19 -0
  267. package/src/modules/shipping_carriers/lib/shipping-service.ts +204 -0
  268. package/src/modules/shipping_carriers/lib/status-sync.ts +38 -0
  269. package/src/modules/shipping_carriers/migrations/Migration20260305170000.ts +14 -0
  270. package/src/modules/shipping_carriers/setup.ts +11 -0
  271. package/src/modules/shipping_carriers/widgets/injection/create-shipment-button/widget.ts +24 -0
  272. package/src/modules/shipping_carriers/widgets/injection/tracking-column/widget.ts +22 -0
  273. package/src/modules/shipping_carriers/widgets/injection/tracking-status-badge/widget.tsx +44 -0
  274. package/src/modules/shipping_carriers/widgets/injection-table.ts +22 -0
  275. package/src/modules/shipping_carriers/workers/status-poller.ts +33 -0
  276. package/src/modules/shipping_carriers/workers/webhook-processor.ts +79 -0
  277. package/src/modules/translations/api/get/locales.ts +1 -0
  278. package/src/modules/translations/api/put/locales.ts +1 -0
@@ -1,8 +1,13 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import Link from 'next/link'
4
- import { useParams } from 'next/navigation'
3
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
4
+ import { z } from 'zod'
5
5
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
7
+ import { WebhookSetupGuide } from '@open-mercato/ui/backend/WebhookSetupGuide'
8
+ import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
6
11
  import { Card, CardHeader, CardTitle, CardContent } from '@open-mercato/ui/primitives/card'
7
12
  import { Badge } from '@open-mercato/ui/primitives/badge'
8
13
  import { Button } from '@open-mercato/ui/primitives/button'
@@ -10,14 +15,24 @@ import { Switch } from '@open-mercato/ui/primitives/switch'
10
15
  import { Input } from '@open-mercato/ui/primitives/input'
11
16
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
17
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
18
+ import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay'
13
19
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
14
20
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
21
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
15
22
  import { useT } from '@open-mercato/shared/lib/i18n/context'
16
- import type { CredentialFieldType, IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
17
- import { LoadingMessage } from '@open-mercato/ui/backend/detail'
18
- import { ErrorMessage } from '@open-mercato/ui/backend/detail'
23
+ import { LEGACY_INTEGRATION_DETAIL_TABS_SPOT_ID, type CredentialFieldType, type IntegrationCredentialField } from '@open-mercato/shared/modules/integrations/types'
24
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
25
+ import { Bell, ChevronDown, ChevronRight, CreditCard, HardDrive, MessageSquare, RefreshCw, Truck, Webhook, Zap } from 'lucide-react'
26
+ import {
27
+ buildIntegrationDetailInjectedTabs,
28
+ filterIntegrationDetailWidgetsByKind,
29
+ resolveIntegrationDetailWidgetSpotId,
30
+ resolveRequestedIntegrationDetailTab,
31
+ } from '../detail-page-widgets'
19
32
 
20
33
  type CredentialField = IntegrationCredentialField
34
+ type BuiltInIntegrationDetailTab = 'credentials' | 'version' | 'health' | 'logs'
35
+ type IntegrationDetailTab = BuiltInIntegrationDetailTab | string
21
36
 
22
37
  const UNSUPPORTED_CREDENTIAL_FIELD_TYPES = new Set<CredentialFieldType>(['oauth', 'ssh_keypair'])
23
38
 
@@ -43,6 +58,9 @@ type IntegrationDetail = {
43
58
  bundleId?: string
44
59
  docsUrl?: string
45
60
  apiVersions?: ApiVersion[]
61
+ detailPage?: {
62
+ widgetSpotId?: string
63
+ }
46
64
  credentials?: { fields: CredentialField[] }
47
65
  }
48
66
  bundle?: { id: string; title: string; credentials?: { fields: CredentialField[] } }
@@ -58,10 +76,27 @@ type IntegrationDetail = {
58
76
 
59
77
  type LogEntry = {
60
78
  id: string
79
+ runId?: string | null
80
+ scopeEntityType?: string | null
81
+ scopeEntityId?: string | null
61
82
  level: 'info' | 'warn' | 'error'
62
83
  message: string
63
84
  createdAt: string
64
- code?: string
85
+ code?: string | null
86
+ payload?: Record<string, unknown> | null
87
+ }
88
+
89
+ type IntegrationDetailPageProps = {
90
+ params?: {
91
+ id?: string | string[]
92
+ }
93
+ }
94
+
95
+ type HealthCheckResponse = {
96
+ status: 'healthy' | 'degraded' | 'unhealthy'
97
+ message: string | null
98
+ details: Record<string, unknown> | null
99
+ checkedAt: string
65
100
  }
66
101
 
67
102
  const LOG_LEVEL_STYLES: Record<string, string> = {
@@ -76,9 +111,152 @@ const HEALTH_STATUS_STYLES: Record<string, string> = {
76
111
  unhealthy: 'bg-red-100 text-red-800',
77
112
  }
78
113
 
79
- export default function IntegrationDetailPage() {
80
- const params = useParams<{ id: string }>()
81
- const integrationId = params.id
114
+ const CATEGORY_ICONS: Record<string, React.ElementType> = {
115
+ payment: CreditCard,
116
+ shipping: Truck,
117
+ data_sync: RefreshCw,
118
+ communication: MessageSquare,
119
+ notification: Bell,
120
+ storage: HardDrive,
121
+ webhook: Webhook,
122
+ }
123
+
124
+ function resolveRouteId(value: string | string[] | undefined): string | undefined {
125
+ if (Array.isArray(value)) return value[0]
126
+ return value
127
+ }
128
+
129
+ function resolvePathnameId(pathname: string): string | undefined {
130
+ const parts = pathname.split('/').filter(Boolean)
131
+ const integrationId = parts.at(-1)
132
+ if (!integrationId || integrationId === 'integrations' || integrationId === 'bundle') return undefined
133
+ return decodeURIComponent(integrationId)
134
+ }
135
+
136
+ function buildCredentialFields(credFields: CredentialField[]): CrudField[] {
137
+ return credFields.map((field) => {
138
+ const shared = {
139
+ id: field.key,
140
+ label: field.label,
141
+ description: field.helpDetails ? (
142
+ <div className="space-y-1">
143
+ {field.helpText ? <div>{field.helpText}</div> : null}
144
+ <WebhookSetupGuide guide={field.helpDetails} buttonLabel="Show details" />
145
+ </div>
146
+ ) : field.helpText,
147
+ placeholder: field.placeholder,
148
+ required: field.required,
149
+ }
150
+
151
+ if (field.type === 'secret') {
152
+ return {
153
+ ...shared,
154
+ type: 'custom' as const,
155
+ component: ({ id, value, setValue, disabled }) => (
156
+ <Input
157
+ id={id}
158
+ type="password"
159
+ placeholder={field.placeholder}
160
+ value={typeof value === 'string' ? value : ''}
161
+ onChange={(event) => setValue(event.target.value)}
162
+ disabled={disabled}
163
+ />
164
+ ),
165
+ }
166
+ }
167
+
168
+ if (field.type === 'select' && field.options) {
169
+ return {
170
+ ...shared,
171
+ type: 'select' as const,
172
+ options: field.options,
173
+ }
174
+ }
175
+
176
+ if (field.type === 'boolean') {
177
+ return {
178
+ ...shared,
179
+ type: 'checkbox' as const,
180
+ }
181
+ }
182
+
183
+ return {
184
+ ...shared,
185
+ type: 'text' as const,
186
+ }
187
+ })
188
+ }
189
+
190
+ function isHealthLog(log: LogEntry): boolean {
191
+ return log.message === 'Health check passed' || log.message.startsWith('Health check:')
192
+ }
193
+
194
+ function extractHealthDetails(payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
195
+ if (!payload) return {}
196
+ return Object.fromEntries(
197
+ Object.entries(payload).filter(([key, value]) => key !== 'status' && key !== 'message' && value !== undefined && value !== null),
198
+ )
199
+ }
200
+
201
+ function formatHealthValue(value: unknown): string {
202
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
203
+ if (typeof value === 'string') return value
204
+ if (typeof value === 'number') return String(value)
205
+ if (value instanceof Date) return value.toLocaleString()
206
+ return JSON.stringify(value)
207
+ }
208
+
209
+ function formatTypeLabel(value: string): string {
210
+ return value.split('_').filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(' ')
211
+ }
212
+
213
+ function isPrimitiveLogValue(value: unknown): value is string | number | boolean | null {
214
+ return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
215
+ }
216
+
217
+ function formatLogDetailLabel(key: string): string {
218
+ return key
219
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
220
+ .replace(/[_-]+/g, ' ')
221
+ .split(' ')
222
+ .filter(Boolean)
223
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
224
+ .join(' ')
225
+ }
226
+
227
+ function formatLogPrimitiveValue(value: string | number | boolean | null): string {
228
+ if (value === null) return 'None'
229
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
230
+ return String(value)
231
+ }
232
+
233
+ function splitLogPayload(payload: Record<string, unknown> | null | undefined) {
234
+ if (!payload) {
235
+ return {
236
+ inlineEntries: [] as Array<[string, string | number | boolean | null]>,
237
+ nestedEntries: [] as Array<[string, unknown]>,
238
+ }
239
+ }
240
+
241
+ const inlineEntries: Array<[string, string | number | boolean | null]> = []
242
+ const nestedEntries: Array<[string, unknown]> = []
243
+
244
+ Object.entries(payload).forEach(([key, value]) => {
245
+ if (isPrimitiveLogValue(value)) {
246
+ inlineEntries.push([key, value])
247
+ return
248
+ }
249
+ nestedEntries.push([key, value])
250
+ })
251
+
252
+ return { inlineEntries, nestedEntries }
253
+ }
254
+
255
+ export default function IntegrationDetailPage({ params }: IntegrationDetailPageProps) {
256
+ const pathname = usePathname()
257
+ const router = useRouter()
258
+ const searchParams = useSearchParams()
259
+ const integrationId = resolveRouteId(params?.id) ?? resolvePathnameId(pathname)
82
260
  const t = useT()
83
261
 
84
262
  const [detail, setDetail] = React.useState<IntegrationDetail | null>(null)
@@ -86,46 +264,76 @@ export default function IntegrationDetailPage() {
86
264
  const [error, setError] = React.useState<string | null>(null)
87
265
 
88
266
  const [credValues, setCredValues] = React.useState<Record<string, unknown>>({})
89
- const [isSavingCreds, setIsSavingCreds] = React.useState(false)
267
+ const [credentialsFormKey, setCredentialsFormKey] = React.useState(0)
268
+ const [isSavingCredentials, setIsSavingCredentials] = React.useState(false)
90
269
 
91
270
  const [logs, setLogs] = React.useState<LogEntry[]>([])
92
271
  const [logLevel, setLogLevel] = React.useState<string>('')
93
272
  const [isLoadingLogs, setIsLoadingLogs] = React.useState(false)
273
+ const [expandedLogId, setExpandedLogId] = React.useState<string | null>(null)
94
274
 
95
275
  const [isCheckingHealth, setIsCheckingHealth] = React.useState(false)
96
276
  const [isTogglingState, setIsTogglingState] = React.useState(false)
277
+ const [latestHealthResult, setLatestHealthResult] = React.useState<HealthCheckResponse | null>(null)
278
+ const [activeTab, setActiveTab] = React.useState<IntegrationDetailTab>('credentials')
97
279
 
98
- const loadDetail = React.useCallback(async () => {
99
- setIsLoading(true)
100
- setError(null)
101
- const call = await apiCall<IntegrationDetail>(
102
- `/api/integrations/${encodeURIComponent(integrationId)}`,
103
- undefined,
104
- { fallback: null },
280
+ const credentialsFormId = React.useId()
281
+
282
+ const resolveCurrentIntegrationId = React.useCallback(() => {
283
+ return integrationId ?? (
284
+ typeof window !== 'undefined'
285
+ ? resolvePathnameId(window.location.pathname)
286
+ : undefined
105
287
  )
106
- if (!call.ok || !call.result) {
107
- setError(t('integrations.detail.loadError'))
288
+ }, [integrationId])
289
+
290
+ const loadDetail = React.useCallback(async () => {
291
+ const currentIntegrationId = resolveCurrentIntegrationId()
292
+ if (!currentIntegrationId) {
108
293
  setIsLoading(false)
294
+ setError(t('integrations.detail.loadError', 'Failed to load integration'))
109
295
  return
110
296
  }
111
- setDetail(call.result)
112
- setIsLoading(false)
113
- }, [integrationId, t])
297
+ setError(null)
298
+ setIsLoading(true)
299
+ try {
300
+ const call = await apiCall<IntegrationDetail>(
301
+ `/api/integrations/${encodeURIComponent(currentIntegrationId)}`,
302
+ undefined,
303
+ { fallback: null },
304
+ )
305
+ if (!call.ok || !call.result) {
306
+ setError(t('integrations.detail.loadError', 'Failed to load integration'))
307
+ setIsLoading(false)
308
+ return
309
+ }
310
+ setDetail(call.result)
311
+ setIsLoading(false)
312
+ } catch {
313
+ setError(t('integrations.detail.loadError', 'Failed to load integration'))
314
+ setIsLoading(false)
315
+ }
316
+ }, [resolveCurrentIntegrationId, t])
114
317
 
115
318
  const loadCredentials = React.useCallback(async () => {
319
+ const currentIntegrationId = resolveCurrentIntegrationId()
320
+ if (!currentIntegrationId) return
116
321
  const call = await apiCall<{ credentials: Record<string, unknown> }>(
117
- `/api/integrations/${encodeURIComponent(integrationId)}/credentials`,
322
+ `/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`,
118
323
  undefined,
119
324
  { fallback: null },
120
325
  )
121
326
  if (call.ok && call.result?.credentials) {
122
327
  setCredValues(call.result.credentials)
328
+ setCredentialsFormKey((current) => current + 1)
123
329
  }
124
- }, [integrationId])
330
+ }, [resolveCurrentIntegrationId])
125
331
 
126
332
  const loadLogs = React.useCallback(async () => {
333
+ const currentIntegrationId = resolveCurrentIntegrationId()
334
+ if (!currentIntegrationId) return
127
335
  setIsLoadingLogs(true)
128
- const params = new URLSearchParams({ integrationId, pageSize: '50' })
336
+ const params = new URLSearchParams({ integrationId: currentIntegrationId, pageSize: '50' })
129
337
  if (logLevel) params.set('level', logLevel)
130
338
  const call = await apiCall<{ items: LogEntry[] }>(
131
339
  `/api/integrations/logs?${params.toString()}`,
@@ -136,222 +344,562 @@ export default function IntegrationDetailPage() {
136
344
  setLogs(call.result.items)
137
345
  }
138
346
  setIsLoadingLogs(false)
139
- }, [integrationId, logLevel])
347
+ }, [logLevel, resolveCurrentIntegrationId])
348
+
349
+ const detailWidgetSpotId = React.useMemo(
350
+ () => resolveIntegrationDetailWidgetSpotId(detail?.integration ?? null, LEGACY_INTEGRATION_DETAIL_TABS_SPOT_ID),
351
+ [detail?.integration],
352
+ )
353
+ const mutationContextId = React.useMemo(
354
+ () => `integrations.detail:${integrationId ?? 'unknown'}`,
355
+ [integrationId],
356
+ )
357
+ const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
358
+ contextId: mutationContextId,
359
+ spotId: detailWidgetSpotId,
360
+ })
361
+ const refreshDetail = React.useCallback(async () => {
362
+ await loadDetail()
363
+ await loadCredentials()
364
+ }, [loadCredentials, loadDetail])
365
+ const refreshLogs = React.useCallback(async () => {
366
+ await loadLogs()
367
+ }, [loadLogs])
368
+ const injectionContext = React.useMemo(
369
+ () => ({
370
+ formId: mutationContextId,
371
+ integrationDetailWidgetSpotId: detailWidgetSpotId,
372
+ resourceKind: 'integrations.integration',
373
+ resourceId: integrationId ?? detail?.integration.id,
374
+ integrationId: integrationId ?? detail?.integration.id,
375
+ integration: detail?.integration ?? null,
376
+ detail,
377
+ state: detail?.state ?? null,
378
+ credentialValues: credValues,
379
+ latestHealthResult,
380
+ activeTab,
381
+ setActiveTab,
382
+ refreshDetail,
383
+ refreshLogs,
384
+ retryLastMutation,
385
+ }),
386
+ [
387
+ activeTab,
388
+ credValues,
389
+ detail,
390
+ detailWidgetSpotId,
391
+ integrationId,
392
+ latestHealthResult,
393
+ mutationContextId,
394
+ refreshDetail,
395
+ refreshLogs,
396
+ retryLastMutation,
397
+ ],
398
+ )
399
+ const { widgets: detailWidgets } = useInjectionWidgets(detailWidgetSpotId, {
400
+ context: injectionContext,
401
+ triggerOnLoad: true,
402
+ })
403
+ const stackedDetailWidgets = React.useMemo(
404
+ () => filterIntegrationDetailWidgetsByKind(detailWidgets, 'stack'),
405
+ [detailWidgets],
406
+ )
407
+ const groupedDetailWidgets = React.useMemo(
408
+ () => filterIntegrationDetailWidgetsByKind(detailWidgets, 'group'),
409
+ [detailWidgets],
410
+ )
411
+ const injectedTabs = React.useMemo(
412
+ () => buildIntegrationDetailInjectedTabs(
413
+ detailWidgets,
414
+ (widget) => (
415
+ widget.placement?.groupLabel
416
+ ? t(widget.placement.groupLabel, widget.module.metadata.title ?? widget.widgetId)
417
+ : (widget.module.metadata.title ?? widget.widgetId)
418
+ ),
419
+ ),
420
+ [detailWidgets, t],
421
+ )
422
+ const customTabIds = React.useMemo(
423
+ () => injectedTabs.map((tab) => tab.id),
424
+ [injectedTabs],
425
+ )
426
+ const runMutationWithContext = React.useCallback(
427
+ async <T,>({
428
+ operation,
429
+ mutationPayload,
430
+ actionId,
431
+ tabId,
432
+ operationType = 'update',
433
+ }: {
434
+ operation: () => Promise<T>
435
+ mutationPayload?: Record<string, unknown>
436
+ actionId: string
437
+ tabId?: string
438
+ operationType?: 'create' | 'update' | 'delete'
439
+ }): Promise<T> => {
440
+ return runMutation({
441
+ operation,
442
+ mutationPayload,
443
+ context: {
444
+ ...injectionContext,
445
+ operation: operationType,
446
+ actionId,
447
+ activeTab: tabId ?? activeTab,
448
+ },
449
+ })
450
+ },
451
+ [activeTab, injectionContext, runMutation],
452
+ )
140
453
 
141
454
  React.useEffect(() => { void loadDetail() }, [loadDetail])
142
455
  React.useEffect(() => { void loadCredentials() }, [loadCredentials])
143
456
  React.useEffect(() => { void loadLogs() }, [loadLogs])
457
+ React.useEffect(() => {
458
+ setExpandedLogId((current) => (current && logs.some((log) => log.id === current) ? current : null))
459
+ }, [logs])
144
460
 
145
461
  const handleToggleState = React.useCallback(async (enabled: boolean) => {
462
+ const currentIntegrationId = resolveCurrentIntegrationId()
463
+ if (!currentIntegrationId) return
146
464
  setIsTogglingState(true)
147
- const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/state`, {
148
- method: 'PUT',
149
- headers: { 'Content-Type': 'application/json' },
150
- body: JSON.stringify({ isEnabled: enabled }),
151
- }, { fallback: null })
152
- if (call.ok) {
153
- setDetail((prev) => prev ? { ...prev, state: { ...prev.state, isEnabled: enabled } } : prev)
154
- flash(t('integrations.detail.stateUpdated'), 'success')
155
- } else {
465
+ try {
466
+ const call = await runMutationWithContext({
467
+ actionId: 'toggle-state',
468
+ mutationPayload: { integrationId: currentIntegrationId, isEnabled: enabled },
469
+ operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/state`, {
470
+ method: 'PUT',
471
+ headers: { 'Content-Type': 'application/json' },
472
+ body: JSON.stringify({ isEnabled: enabled }),
473
+ }, { fallback: null }),
474
+ })
475
+ if (call.ok) {
476
+ setDetail((prev) => prev ? { ...prev, state: { ...prev.state, isEnabled: enabled } } : prev)
477
+ flash(t('integrations.detail.stateUpdated'), 'success')
478
+ } else {
479
+ flash(t('integrations.detail.stateError'), 'error')
480
+ }
481
+ } catch {
156
482
  flash(t('integrations.detail.stateError'), 'error')
483
+ } finally {
484
+ setIsTogglingState(false)
157
485
  }
158
- setIsTogglingState(false)
159
- }, [integrationId, t])
160
-
161
- const handleSaveCredentials = React.useCallback(async () => {
162
- setIsSavingCreds(true)
163
- const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/credentials`, {
164
- method: 'PUT',
165
- headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({ credentials: credValues }),
167
- }, { fallback: null })
168
- if (call.ok) {
169
- flash(t('integrations.detail.credentials.saved'), 'success')
170
- } else {
171
- flash(t('integrations.detail.credentials.saveError'), 'error')
486
+ }, [resolveCurrentIntegrationId, runMutationWithContext, t])
487
+
488
+ const handleSaveCredentials = React.useCallback(async (values: Record<string, unknown>) => {
489
+ const currentIntegrationId = resolveCurrentIntegrationId()
490
+ if (!currentIntegrationId) return
491
+ setIsSavingCredentials(true)
492
+ try {
493
+ const call = await runMutationWithContext({
494
+ actionId: 'save-credentials',
495
+ tabId: 'credentials',
496
+ mutationPayload: { integrationId: currentIntegrationId, credentials: values },
497
+ operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/credentials`, {
498
+ method: 'PUT',
499
+ headers: { 'Content-Type': 'application/json' },
500
+ body: JSON.stringify({ credentials: values }),
501
+ }, { fallback: null }),
502
+ })
503
+
504
+ if (call.ok) {
505
+ setCredValues(values)
506
+ setCredentialsFormKey((current) => current + 1)
507
+ flash(t('integrations.detail.credentials.saved'), 'success')
508
+ return
509
+ }
510
+
511
+ const result = call.result as {
512
+ error?: string
513
+ details?: { fieldErrors?: Record<string, string>; formErrors?: string[] }
514
+ } | null
515
+ throw createCrudFormError(
516
+ result?.error ?? t('integrations.detail.credentials.saveError', 'Failed to save credentials'),
517
+ result?.details?.fieldErrors,
518
+ { details: result?.details },
519
+ )
520
+ } finally {
521
+ setIsSavingCredentials(false)
172
522
  }
173
- setIsSavingCreds(false)
174
- }, [integrationId, credValues, t])
523
+ }, [resolveCurrentIntegrationId, runMutationWithContext, t])
175
524
 
176
525
  const handleVersionChange = React.useCallback(async (version: string) => {
177
- const call = await apiCall(`/api/integrations/${encodeURIComponent(integrationId)}/version`, {
178
- method: 'PUT',
179
- headers: { 'Content-Type': 'application/json' },
180
- body: JSON.stringify({ apiVersion: version }),
181
- }, { fallback: null })
182
- if (call.ok) {
183
- setDetail((prev) => prev ? { ...prev, state: { ...prev.state, apiVersion: version } } : prev)
184
- flash(t('integrations.detail.version.saved'), 'success')
185
- } else {
526
+ const currentIntegrationId = resolveCurrentIntegrationId()
527
+ if (!currentIntegrationId) return
528
+ try {
529
+ const call = await runMutationWithContext({
530
+ actionId: 'change-version',
531
+ tabId: 'version',
532
+ mutationPayload: { integrationId: currentIntegrationId, apiVersion: version },
533
+ operation: () => apiCall(`/api/integrations/${encodeURIComponent(currentIntegrationId)}/version`, {
534
+ method: 'PUT',
535
+ headers: { 'Content-Type': 'application/json' },
536
+ body: JSON.stringify({ apiVersion: version }),
537
+ }, { fallback: null }),
538
+ })
539
+ if (call.ok) {
540
+ setDetail((prev) => prev ? { ...prev, state: { ...prev.state, apiVersion: version } } : prev)
541
+ flash(t('integrations.detail.version.saved'), 'success')
542
+ } else {
543
+ flash(t('integrations.detail.version.saveError'), 'error')
544
+ }
545
+ } catch {
186
546
  flash(t('integrations.detail.version.saveError'), 'error')
187
547
  }
188
- }, [integrationId, t])
548
+ }, [resolveCurrentIntegrationId, runMutationWithContext, t])
189
549
 
190
550
  const handleHealthCheck = React.useCallback(async () => {
551
+ const currentIntegrationId = resolveCurrentIntegrationId()
552
+ if (!currentIntegrationId) return
191
553
  setIsCheckingHealth(true)
192
- const call = await apiCall<{ status: string; checkedAt: string }>(
193
- `/api/integrations/${encodeURIComponent(integrationId)}/health`,
194
- { method: 'POST' },
195
- { fallback: null },
196
- )
197
- if (call.ok && call.result) {
198
- setDetail((prev) => prev ? {
199
- ...prev,
200
- state: {
201
- ...prev.state,
202
- lastHealthStatus: call.result!.status,
203
- lastHealthCheckedAt: call.result!.checkedAt,
204
- },
205
- } : prev)
206
- } else {
554
+ try {
555
+ const call = await runMutationWithContext({
556
+ actionId: 'run-health-check',
557
+ tabId: 'health',
558
+ mutationPayload: { integrationId: currentIntegrationId },
559
+ operation: () => apiCall<HealthCheckResponse>(
560
+ `/api/integrations/${encodeURIComponent(currentIntegrationId)}/health`,
561
+ { method: 'POST' },
562
+ { fallback: null },
563
+ ),
564
+ })
565
+ const result = call.result
566
+ if (call.ok && result) {
567
+ setLatestHealthResult(result)
568
+ setDetail((prev) => prev ? {
569
+ ...prev,
570
+ state: {
571
+ ...prev.state,
572
+ lastHealthStatus: result.status,
573
+ lastHealthCheckedAt: result.checkedAt,
574
+ },
575
+ } : prev)
576
+ void refreshLogs()
577
+ } else {
578
+ flash(t('integrations.detail.health.checkError'), 'error')
579
+ }
580
+ } catch {
207
581
  flash(t('integrations.detail.health.checkError'), 'error')
582
+ } finally {
583
+ setIsCheckingHealth(false)
208
584
  }
209
- setIsCheckingHealth(false)
210
- }, [integrationId, t])
585
+ }, [refreshLogs, resolveCurrentIntegrationId, runMutationWithContext, t])
586
+
587
+ const hasVersions = Boolean(detail?.integration.apiVersions?.length)
588
+ const integration = detail?.integration ?? null
589
+ const state = detail?.state ?? null
590
+ const editableCredentialFields = React.useMemo(
591
+ () => (detail?.integration.credentials?.fields ?? detail?.bundle?.credentials?.fields ?? []).filter(isEditableCredentialField),
592
+ [detail?.bundle?.credentials?.fields, detail?.integration.credentials?.fields],
593
+ )
594
+ const credentialFormFields = React.useMemo(
595
+ () => buildCredentialFields(editableCredentialFields),
596
+ [editableCredentialFields],
597
+ )
598
+ const credentialSchema = React.useMemo(() => (
599
+ z.object({}).passthrough().superRefine((rawValues, ctx) => {
600
+ const values = rawValues as Record<string, unknown>
601
+
602
+ editableCredentialFields.forEach((field) => {
603
+ const value = values[field.key]
604
+
605
+ if (field.type === 'boolean') {
606
+ if (value !== undefined && value !== null && typeof value !== 'boolean') {
607
+ ctx.addIssue({
608
+ code: z.ZodIssueCode.custom,
609
+ path: [field.key],
610
+ message: t('integrations.detail.credentials.validation.boolean', 'Select a valid value.'),
611
+ })
612
+ }
613
+ if (field.required && typeof value !== 'boolean') {
614
+ ctx.addIssue({
615
+ code: z.ZodIssueCode.custom,
616
+ path: [field.key],
617
+ message: t('integrations.detail.credentials.validation.required', '{field} is required.', { field: field.label }),
618
+ })
619
+ }
620
+ return
621
+ }
622
+
623
+ if (value !== undefined && value !== null && typeof value !== 'string') {
624
+ ctx.addIssue({
625
+ code: z.ZodIssueCode.custom,
626
+ path: [field.key],
627
+ message: t('integrations.detail.credentials.validation.text', 'Enter a valid value.'),
628
+ })
629
+ return
630
+ }
631
+
632
+ const normalizedValue = typeof value === 'string' ? value : ''
633
+
634
+ if (field.required && normalizedValue.trim().length === 0) {
635
+ ctx.addIssue({
636
+ code: z.ZodIssueCode.custom,
637
+ path: [field.key],
638
+ message: t('integrations.detail.credentials.validation.required', '{field} is required.', { field: field.label }),
639
+ })
640
+ }
641
+
642
+ if (normalizedValue.length > 20_000) {
643
+ ctx.addIssue({
644
+ code: z.ZodIssueCode.custom,
645
+ path: [field.key],
646
+ message: t('integrations.detail.credentials.validation.tooLong', 'Value is too long.'),
647
+ })
648
+ }
649
+
650
+ if (
651
+ field.type === 'select'
652
+ && normalizedValue
653
+ && field.options
654
+ && !field.options.some((option) => option.value === normalizedValue)
655
+ ) {
656
+ ctx.addIssue({
657
+ code: z.ZodIssueCode.custom,
658
+ path: [field.key],
659
+ message: t('integrations.detail.credentials.validation.option', 'Select one of the available options.'),
660
+ })
661
+ }
662
+ })
663
+ })
664
+ ) as z.ZodType<Record<string, unknown>>, [editableCredentialFields, t])
665
+ const latestHealthLog = React.useMemo(() => logs.find(isHealthLog) ?? null, [logs])
666
+ const healthMessage =
667
+ latestHealthResult?.message ??
668
+ (typeof latestHealthLog?.payload?.message === 'string' ? latestHealthLog.payload.message : null)
669
+ const healthDetailsSource = latestHealthResult?.details ?? extractHealthDetails(latestHealthLog?.payload)
670
+ const healthDetails = latestHealthLog?.code
671
+ ? { ...healthDetailsSource, code: latestHealthLog.code }
672
+ : healthDetailsSource
673
+ const healthDetailEntries = Object.entries(healthDetails)
674
+ const healthStatusDescription = state?.lastHealthStatus
675
+ ? t(
676
+ `integrations.detail.health.meaning.${state.lastHealthStatus}`,
677
+ state.lastHealthStatus === 'healthy'
678
+ ? 'The provider responded successfully using the current credentials.'
679
+ : state.lastHealthStatus === 'degraded'
680
+ ? 'The provider responded, but reported warnings or limited functionality.'
681
+ : integration?.id === 'gateway_stripe'
682
+ ? 'Stripe rejected the last check. This usually means the secret key is invalid, missing required permissions, revoked, or Stripe was temporarily unavailable.'
683
+ : 'The last check failed. This usually means invalid credentials, missing permissions, or a provider outage.',
684
+ )
685
+ : null
686
+
687
+ React.useEffect(() => {
688
+ setActiveTab(resolveRequestedIntegrationDetailTab(searchParams?.get('tab'), hasVersions, customTabIds))
689
+ }, [customTabIds, hasVersions, searchParams])
690
+
691
+ const handleTabChange = React.useCallback((nextValue: string) => {
692
+ const currentIntegrationId = resolveCurrentIntegrationId()
693
+ const nextTab = resolveRequestedIntegrationDetailTab(nextValue, hasVersions, customTabIds)
694
+ setActiveTab(nextTab)
695
+ if (!currentIntegrationId) return
696
+ const basePath = `/backend/integrations/${encodeURIComponent(currentIntegrationId)}`
697
+ router.replace(nextTab === 'credentials' ? basePath : `${basePath}?tab=${encodeURIComponent(nextTab)}`)
698
+ }, [customTabIds, hasVersions, resolveCurrentIntegrationId, router])
211
699
 
212
700
  if (isLoading) return <Page><PageBody><LoadingMessage label={t('integrations.detail.title')} /></PageBody></Page>
213
701
  if (error || !detail) return <Page><PageBody><ErrorMessage label={error ?? t('integrations.detail.loadError')} /></PageBody></Page>
214
702
 
215
- const { integration, state } = detail
216
- const credFields = integration.credentials?.fields ?? detail.bundle?.credentials?.fields ?? []
217
- const hasVersions = Boolean(integration.apiVersions?.length)
703
+ const resolvedIntegration = detail.integration
704
+ const resolvedState = detail.state
705
+ const CategoryIcon = resolvedIntegration.category ? CATEGORY_ICONS[resolvedIntegration.category] : null
706
+
707
+ const showCredentialActions = activeTab === 'credentials' && credentialFormFields.length > 0
218
708
 
219
709
  return (
220
710
  <Page>
221
711
  <PageBody className="space-y-6">
222
- <div>
223
- <Link href="/backend/integrations" className="text-sm text-muted-foreground hover:underline">
224
- {t('integrations.detail.back')}
225
- </Link>
712
+ <FormHeader
713
+ backHref="/backend/integrations"
714
+ title={resolvedIntegration.title}
715
+ actions={{
716
+ cancelHref: showCredentialActions ? '/backend/integrations' : undefined,
717
+ submit: showCredentialActions
718
+ ? {
719
+ formId: credentialsFormId,
720
+ pending: isSavingCredentials,
721
+ label: t('integrations.detail.credentials.save', 'Save credentials'),
722
+ pendingLabel: t('ui.forms.status.saving', 'Saving...'),
723
+ }
724
+ : undefined,
725
+ }}
726
+ />
727
+
728
+ <div className="space-y-2">
729
+ {resolvedIntegration.description ? (
730
+ <p className="text-sm text-muted-foreground">{resolvedIntegration.description}</p>
731
+ ) : null}
732
+ <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
733
+ {resolvedIntegration.category ? (
734
+ <div className="flex items-center gap-2">
735
+ {CategoryIcon ? <CategoryIcon className="h-4 w-4" /> : null}
736
+ <span>{formatTypeLabel(resolvedIntegration.category)}</span>
737
+ </div>
738
+ ) : null}
739
+ {resolvedIntegration.hub ? (
740
+ <div className="flex items-center gap-2">
741
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground/80">
742
+ {t('integrations.detail.hub.label', 'Hub')}
743
+ </span>
744
+ <span>{formatTypeLabel(resolvedIntegration.hub)}</span>
745
+ </div>
746
+ ) : null}
747
+ </div>
226
748
  </div>
227
749
 
228
- <div className="flex items-center justify-between">
229
- <div>
230
- <h1 className="text-2xl font-semibold">{integration.title}</h1>
231
- {integration.description && (
232
- <p className="text-muted-foreground mt-1">{integration.description}</p>
233
- )}
234
- <div className="flex gap-2 mt-2">
235
- {integration.category && <Badge variant="secondary">{integration.category}</Badge>}
236
- {integration.hub && <Badge variant="outline">{integration.hub}</Badge>}
750
+ <section className="rounded-lg border bg-card p-4">
751
+ <div className="flex items-center justify-between gap-4">
752
+ <div className="space-y-1">
753
+ <p className="text-[11px] uppercase tracking-wide text-muted-foreground">
754
+ {t('integrations.detail.state.label', 'State')}
755
+ </p>
756
+ <p className="text-sm font-medium">
757
+ {resolvedState.isEnabled
758
+ ? t('integrations.detail.state.enabled', 'Enabled')
759
+ : t('integrations.detail.state.disabled', 'Disabled')}
760
+ </p>
237
761
  </div>
238
- </div>
239
- <div className="flex items-center gap-3">
240
- <span className="text-sm text-muted-foreground">
241
- {state.isEnabled ? t('integrations.detail.enable') : t('integrations.detail.disable')}
242
- </span>
243
762
  <Switch
244
- checked={state.isEnabled}
763
+ checked={resolvedState.isEnabled}
245
764
  disabled={isTogglingState}
246
765
  onCheckedChange={(checked) => void handleToggleState(checked)}
247
766
  />
248
767
  </div>
249
- </div>
768
+ </section>
769
+
770
+ {stackedDetailWidgets.length > 0 ? (
771
+ <section className="space-y-4">
772
+ <InjectionSpot
773
+ spotId={detailWidgetSpotId}
774
+ context={injectionContext}
775
+ data={detail}
776
+ onDataChange={(next) => setDetail(next as IntegrationDetail)}
777
+ widgetsOverride={stackedDetailWidgets}
778
+ />
779
+ </section>
780
+ ) : null}
781
+
782
+ {groupedDetailWidgets.length > 0 ? (
783
+ <section className="grid gap-4 lg:grid-cols-2">
784
+ {groupedDetailWidgets.map((widget) => (
785
+ <Card
786
+ key={widget.widgetId}
787
+ className={widget.placement?.column === 2 ? 'lg:col-start-2' : undefined}
788
+ >
789
+ <CardHeader>
790
+ <CardTitle>
791
+ {widget.placement?.groupLabel
792
+ ? t(widget.placement.groupLabel, widget.module.metadata.title)
793
+ : widget.module.metadata.title}
794
+ </CardTitle>
795
+ {widget.placement?.groupDescription ? (
796
+ <p className="text-sm text-muted-foreground">
797
+ {widget.placement.groupDescription}
798
+ </p>
799
+ ) : null}
800
+ </CardHeader>
801
+ <CardContent>
802
+ <widget.module.Widget
803
+ context={injectionContext}
804
+ data={detail}
805
+ onDataChange={(next) => setDetail(next as IntegrationDetail)}
806
+ />
807
+ </CardContent>
808
+ </Card>
809
+ ))}
810
+ </section>
811
+ ) : null}
250
812
 
251
- <Tabs defaultValue="credentials">
813
+ <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
252
814
  <TabsList>
253
815
  <TabsTrigger value="credentials">{t('integrations.detail.tabs.credentials')}</TabsTrigger>
254
- {hasVersions && <TabsTrigger value="version">{t('integrations.detail.tabs.version')}</TabsTrigger>}
816
+ {hasVersions ? <TabsTrigger value="version">{t('integrations.detail.tabs.version')}</TabsTrigger> : null}
255
817
  <TabsTrigger value="health">{t('integrations.detail.tabs.health')}</TabsTrigger>
256
818
  <TabsTrigger value="logs">{t('integrations.detail.tabs.logs')}</TabsTrigger>
819
+ {injectedTabs.map((tab) => (
820
+ <TabsTrigger key={tab.id} value={tab.id}>{tab.label}</TabsTrigger>
821
+ ))}
257
822
  </TabsList>
258
823
 
259
- <TabsContent value="credentials" className="space-y-4 mt-4">
260
- {detail.bundle && (
261
- <div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
262
- {t('integrations.detail.credentials.bundleShared', { bundle: detail.bundle.title })}
263
- </div>
264
- )}
265
- {credFields.length === 0 ? (
266
- <p className="text-muted-foreground text-sm">{t('integrations.detail.credentials.notConfigured')}</p>
267
- ) : (
268
- <Card>
269
- <CardContent className="pt-6 space-y-4">
270
- {credFields.filter(isEditableCredentialField).map((field) => (
271
- <div key={field.key} className="space-y-1.5">
272
- <label className="text-sm font-medium">
273
- {field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
274
- </label>
275
- {field.type === 'select' && field.options ? (
276
- <select
277
- className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
278
- value={(credValues[field.key] as string) ?? ''}
279
- onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
280
- >
281
- <option value="">—</option>
282
- {field.options.map((opt) => (
283
- <option key={opt.value} value={opt.value}>{opt.label}</option>
284
- ))}
285
- </select>
286
- ) : field.type === 'boolean' ? (
287
- <Switch
288
- checked={Boolean(credValues[field.key])}
289
- onCheckedChange={(checked) => setCredValues((prev) => ({ ...prev, [field.key]: checked }))}
290
- />
291
- ) : (
292
- <Input
293
- type={field.type === 'secret' ? 'password' : 'text'}
294
- placeholder={field.placeholder}
295
- value={(credValues[field.key] as string) ?? ''}
296
- onChange={(e) => setCredValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
297
- />
298
- )}
299
- </div>
300
- ))}
301
- <Button type="button" onClick={() => void handleSaveCredentials()} disabled={isSavingCreds}>
302
- {isSavingCreds ? <Spinner className="mr-2 h-4 w-4" /> : null}
303
- {t('integrations.detail.credentials.save')}
304
- </Button>
305
- </CardContent>
306
- </Card>
307
- )}
824
+ <TabsContent value="credentials" className="mt-0">
825
+ <section className="space-y-4 rounded-lg border bg-card p-6">
826
+ {detail.bundle ? (
827
+ <div className="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
828
+ {t('integrations.detail.credentials.bundleShared', { bundle: detail.bundle.title })}
829
+ </div>
830
+ ) : null}
831
+ {credentialFormFields.length === 0 ? (
832
+ <p className="text-sm text-muted-foreground">
833
+ {t('integrations.detail.credentials.notConfigured')}
834
+ </p>
835
+ ) : (
836
+ <CrudForm<Record<string, unknown>>
837
+ key={`${resolvedIntegration.id}:${credentialsFormKey}`}
838
+ formId={credentialsFormId}
839
+ entityId="integrations.integration"
840
+ schema={credentialSchema}
841
+ fields={credentialFormFields}
842
+ initialValues={credValues}
843
+ onSubmit={handleSaveCredentials}
844
+ embedded
845
+ hideFooterActions
846
+ />
847
+ )}
848
+ </section>
308
849
  </TabsContent>
309
850
 
310
- {hasVersions && (
311
- <TabsContent value="version" className="space-y-4 mt-4">
851
+ {hasVersions ? (
852
+ <TabsContent value="version" className="mt-0 space-y-4">
312
853
  <Card>
313
854
  <CardHeader>
314
855
  <CardTitle>{t('integrations.detail.version.select')}</CardTitle>
315
856
  </CardHeader>
316
857
  <CardContent className="space-y-3">
317
- {integration.apiVersions!.map((v) => {
318
- const isSelected = (state.apiVersion ?? integration.apiVersions!.find((x) => x.status === 'stable')?.id) === v.id
858
+ {resolvedIntegration.apiVersions?.map((version) => {
859
+ const stableVersion = resolvedIntegration.apiVersions?.find((item) => item.status === 'stable')?.id
860
+ const isSelected = (resolvedState.apiVersion ?? stableVersion) === version.id
319
861
  return (
320
862
  <div
321
- key={v.id}
322
- className={`flex items-center justify-between rounded-lg border p-3 cursor-pointer transition-colors ${isSelected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
323
- onClick={() => void handleVersionChange(v.id)}
863
+ key={version.id}
864
+ className={`flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${isSelected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
865
+ onClick={() => void handleVersionChange(version.id)}
324
866
  >
325
867
  <div>
326
- <span className="font-medium text-sm">{v.label ?? v.id}</span>
868
+ <span className="text-sm font-medium">{version.label ?? version.id}</span>
327
869
  <Badge
328
- variant={v.status === 'stable' ? 'default' : v.status === 'deprecated' ? 'destructive' : 'secondary'}
870
+ variant={version.status === 'stable' ? 'default' : version.status === 'deprecated' ? 'destructive' : 'secondary'}
329
871
  className="ml-2"
330
872
  >
331
- {t(`integrations.detail.version.${v.status}`)}
873
+ {t(`integrations.detail.version.${version.status}`)}
332
874
  </Badge>
333
- {v.status === 'deprecated' && v.sunsetAt && (
334
- <span className="text-xs text-muted-foreground ml-2">
335
- {t('integrations.detail.version.sunsetAt', { date: new Date(v.sunsetAt).toLocaleDateString() })}
875
+ {version.status === 'deprecated' && version.sunsetAt ? (
876
+ <span className="ml-2 text-xs text-muted-foreground">
877
+ {t('integrations.detail.version.sunsetAt', { date: new Date(version.sunsetAt).toLocaleDateString() })}
336
878
  </span>
337
- )}
879
+ ) : null}
338
880
  </div>
339
- {isSelected && <Badge variant="outline">{t('integrations.detail.version.current')}</Badge>}
881
+ {isSelected ? <Badge variant="outline">{t('integrations.detail.version.current')}</Badge> : null}
340
882
  </div>
341
883
  )
342
884
  })}
343
885
  </CardContent>
344
886
  </Card>
345
887
  </TabsContent>
346
- )}
888
+ ) : null}
347
889
 
348
- <TabsContent value="health" className="space-y-4 mt-4">
890
+ <TabsContent value="health" className="mt-0 space-y-4">
349
891
  <Card>
350
892
  <CardHeader>
351
893
  <div className="flex items-center justify-between">
352
894
  <CardTitle>{t('integrations.detail.health.title')}</CardTitle>
353
- <Button type="button" variant="outline" size="sm" onClick={() => void handleHealthCheck()} disabled={isCheckingHealth}>
354
- {isCheckingHealth ? <Spinner className="mr-2 h-4 w-4" /> : null}
895
+ <Button
896
+ type="button"
897
+ variant="outline"
898
+ size="sm"
899
+ onClick={() => void handleHealthCheck()}
900
+ disabled={isCheckingHealth}
901
+ >
902
+ {isCheckingHealth ? <Spinner className="mr-2 h-4 w-4" /> : <Zap className="mr-2 h-4 w-4" />}
355
903
  {isCheckingHealth ? t('integrations.detail.health.checking') : t('integrations.detail.health.check')}
356
904
  </Button>
357
905
  </div>
@@ -359,17 +907,45 @@ export default function IntegrationDetailPage() {
359
907
  <CardContent className="space-y-3">
360
908
  <div className="flex items-center gap-3">
361
909
  <span className="text-sm font-medium">{t('integrations.detail.health.title')}:</span>
362
- {state.lastHealthStatus ? (
363
- <Badge className={HEALTH_STATUS_STYLES[state.lastHealthStatus] ?? ''}>
364
- {t(`integrations.detail.health.${state.lastHealthStatus}`)}
910
+ {resolvedState.lastHealthStatus ? (
911
+ <Badge className={HEALTH_STATUS_STYLES[resolvedState.lastHealthStatus] ?? ''}>
912
+ {t(`integrations.detail.health.${resolvedState.lastHealthStatus}`)}
365
913
  </Badge>
366
914
  ) : (
367
- <span className="text-sm text-muted-foreground">{t('integrations.detail.health.unknown')}</span>
915
+ <span className="text-sm text-muted-foreground">
916
+ {t('integrations.detail.health.unknown')}
917
+ </span>
368
918
  )}
369
919
  </div>
920
+ {healthStatusDescription ? (
921
+ <p className="text-sm text-muted-foreground">{healthStatusDescription}</p>
922
+ ) : null}
923
+ {healthMessage ? (
924
+ <div className="rounded-lg border bg-muted/30 p-3">
925
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
926
+ {t('integrations.detail.health.lastResult', 'Last result')}
927
+ </p>
928
+ <p className="mt-1 text-sm">{healthMessage}</p>
929
+ </div>
930
+ ) : null}
931
+ {healthDetailEntries.length > 0 ? (
932
+ <div className="rounded-lg border p-3">
933
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
934
+ {t('integrations.detail.health.details', 'Details')}
935
+ </p>
936
+ <dl className="mt-3 grid gap-3 sm:grid-cols-2">
937
+ {healthDetailEntries.map(([key, value]) => (
938
+ <div key={key}>
939
+ <dt className="text-xs font-medium text-muted-foreground">{key}</dt>
940
+ <dd className="mt-1 text-sm">{formatHealthValue(value)}</dd>
941
+ </div>
942
+ ))}
943
+ </dl>
944
+ </div>
945
+ ) : null}
370
946
  <p className="text-xs text-muted-foreground">
371
- {state.lastHealthCheckedAt
372
- ? t('integrations.detail.health.lastChecked', { date: new Date(state.lastHealthCheckedAt).toLocaleString() })
947
+ {resolvedState.lastHealthCheckedAt
948
+ ? t('integrations.detail.health.lastChecked', { date: new Date(resolvedState.lastHealthCheckedAt).toLocaleString() })
373
949
  : t('integrations.detail.health.neverChecked')
374
950
  }
375
951
  </p>
@@ -377,23 +953,26 @@ export default function IntegrationDetailPage() {
377
953
  </Card>
378
954
  </TabsContent>
379
955
 
380
- <TabsContent value="logs" className="space-y-4 mt-4">
956
+ <TabsContent value="logs" className="mt-0 space-y-4">
381
957
  <div className="flex items-center gap-3">
382
- <select
383
- className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm"
384
- value={logLevel}
385
- onChange={(e) => setLogLevel(e.target.value)}
386
- >
387
- <option value="">{t('integrations.detail.logs.level.all')}</option>
388
- <option value="info">{t('integrations.detail.logs.level.info')}</option>
389
- <option value="warn">{t('integrations.detail.logs.level.warn')}</option>
390
- <option value="error">{t('integrations.detail.logs.level.error')}</option>
391
- </select>
958
+ <div className="relative inline-flex">
959
+ <select
960
+ className="h-11 min-w-40 appearance-none rounded-xl border border-border bg-card pl-4 pr-11 text-sm font-medium text-foreground shadow-sm transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
961
+ value={logLevel}
962
+ onChange={(event) => setLogLevel(event.target.value)}
963
+ >
964
+ <option value="">{t('integrations.detail.logs.level.all')}</option>
965
+ <option value="info">{t('integrations.detail.logs.level.info')}</option>
966
+ <option value="warn">{t('integrations.detail.logs.level.warn')}</option>
967
+ <option value="error">{t('integrations.detail.logs.level.error')}</option>
968
+ </select>
969
+ <ChevronDown className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
970
+ </div>
392
971
  </div>
393
972
  {isLoadingLogs ? (
394
973
  <div className="flex justify-center py-8"><Spinner /></div>
395
974
  ) : logs.length === 0 ? (
396
- <p className="text-muted-foreground text-sm py-4">{t('integrations.detail.logs.empty')}</p>
975
+ <p className="py-4 text-sm text-muted-foreground">{t('integrations.detail.logs.empty')}</p>
397
976
  ) : (
398
977
  <div className="rounded-lg border">
399
978
  <table className="w-full text-sm">
@@ -405,24 +984,145 @@ export default function IntegrationDetailPage() {
405
984
  </tr>
406
985
  </thead>
407
986
  <tbody>
408
- {logs.map((log) => (
409
- <tr key={log.id} className="border-b last:border-0">
410
- <td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
411
- {new Date(log.createdAt).toLocaleString()}
412
- </td>
413
- <td className="px-4 py-2">
414
- <Badge variant="secondary" className={LOG_LEVEL_STYLES[log.level] ?? ''}>
415
- {log.level}
416
- </Badge>
417
- </td>
418
- <td className="px-4 py-2">{log.message}</td>
419
- </tr>
420
- ))}
987
+ {logs.map((log) => {
988
+ const isExpanded = expandedLogId === log.id
989
+ const metadataEntries = [
990
+ ['Time', new Date(log.createdAt).toLocaleString()],
991
+ ['Level', log.level],
992
+ ['Code', log.code ?? null],
993
+ ['Run ID', log.runId ?? null],
994
+ ['Entity Type', log.scopeEntityType ?? null],
995
+ ['Entity ID', log.scopeEntityId ?? null],
996
+ ].filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim().length > 0)
997
+ const { inlineEntries, nestedEntries } = splitLogPayload(log.payload)
998
+
999
+ return (
1000
+ <React.Fragment key={log.id}>
1001
+ <tr className="border-b last:border-0">
1002
+ <td className="whitespace-nowrap px-4 py-2 text-muted-foreground">
1003
+ {new Date(log.createdAt).toLocaleString()}
1004
+ </td>
1005
+ <td className="px-4 py-2">
1006
+ <Badge variant="secondary" className={LOG_LEVEL_STYLES[log.level] ?? ''}>
1007
+ {log.level}
1008
+ </Badge>
1009
+ </td>
1010
+ <td className="px-4 py-2">
1011
+ <Button
1012
+ type="button"
1013
+ variant="ghost"
1014
+ size="sm"
1015
+ className="h-auto w-full justify-start gap-2 px-0 py-0 text-left hover:bg-transparent"
1016
+ onClick={() => setExpandedLogId((current) => (current === log.id ? null : log.id))}
1017
+ >
1018
+ {isExpanded ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
1019
+ <span className="truncate">{log.message}</span>
1020
+ </Button>
1021
+ </td>
1022
+ </tr>
1023
+ {isExpanded ? (
1024
+ <tr className="border-b bg-muted/20 last:border-0">
1025
+ <td colSpan={3} className="px-4 py-4">
1026
+ <div className="space-y-4 rounded-lg border bg-card p-4">
1027
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
1028
+ <div className="space-y-4">
1029
+ <section className="space-y-3">
1030
+ <div>
1031
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
1032
+ {t('integrations.detail.logs.details.summary', 'Summary')}
1033
+ </p>
1034
+ <p className="mt-1 text-sm font-medium">{log.message}</p>
1035
+ </div>
1036
+ {metadataEntries.length > 0 ? (
1037
+ <dl className="grid gap-3 sm:grid-cols-2">
1038
+ {metadataEntries.map(([label, value]) => (
1039
+ <div key={label} className="rounded-md border bg-muted/30 px-3 py-2">
1040
+ <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1041
+ {label}
1042
+ </dt>
1043
+ <dd className="mt-1 break-all text-sm">{value}</dd>
1044
+ </div>
1045
+ ))}
1046
+ </dl>
1047
+ ) : null}
1048
+ </section>
1049
+
1050
+ {inlineEntries.length > 0 ? (
1051
+ <section className="space-y-3">
1052
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
1053
+ {t('integrations.detail.logs.details.fields', 'Fields')}
1054
+ </p>
1055
+ <dl className="grid gap-3 sm:grid-cols-2">
1056
+ {inlineEntries.map(([key, value]) => (
1057
+ <div key={key} className="rounded-md border bg-muted/30 px-3 py-2">
1058
+ <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1059
+ {formatLogDetailLabel(key)}
1060
+ </dt>
1061
+ <dd className="mt-1 break-words text-sm">
1062
+ {formatLogPrimitiveValue(value)}
1063
+ </dd>
1064
+ </div>
1065
+ ))}
1066
+ </dl>
1067
+ </section>
1068
+ ) : null}
1069
+ </div>
1070
+
1071
+ <div className="space-y-3">
1072
+ {nestedEntries.map(([key, value]) => (
1073
+ <JsonDisplay
1074
+ key={key}
1075
+ data={value}
1076
+ title={formatLogDetailLabel(key)}
1077
+ defaultExpanded
1078
+ maxInitialDepth={1}
1079
+ theme="dark"
1080
+ maxHeight="16rem"
1081
+ className="p-4"
1082
+ />
1083
+ ))}
1084
+ {log.payload && nestedEntries.length === 0 ? (
1085
+ <JsonDisplay
1086
+ data={log.payload}
1087
+ title={t('integrations.detail.logs.details.payload', 'Payload')}
1088
+ defaultExpanded
1089
+ maxInitialDepth={1}
1090
+ theme="dark"
1091
+ maxHeight="16rem"
1092
+ className="p-4"
1093
+ />
1094
+ ) : null}
1095
+ {!log.payload ? (
1096
+ <div className="rounded-lg border border-dashed px-4 py-6 text-sm text-muted-foreground">
1097
+ {t('integrations.detail.logs.details.noPayload', 'No structured payload was stored for this log entry.')}
1098
+ </div>
1099
+ ) : null}
1100
+ </div>
1101
+ </div>
1102
+ </div>
1103
+ </td>
1104
+ </tr>
1105
+ ) : null}
1106
+ </React.Fragment>
1107
+ )
1108
+ })}
421
1109
  </tbody>
422
1110
  </table>
423
1111
  </div>
424
1112
  )}
425
1113
  </TabsContent>
1114
+
1115
+ {injectedTabs.map((tab) => (
1116
+ <TabsContent key={tab.id} value={tab.id} className="mt-0 space-y-4">
1117
+ <InjectionSpot
1118
+ spotId={detailWidgetSpotId}
1119
+ context={injectionContext}
1120
+ data={detail}
1121
+ onDataChange={(next) => setDetail(next as IntegrationDetail)}
1122
+ widgetsOverride={tab.widgets}
1123
+ />
1124
+ </TabsContent>
1125
+ ))}
426
1126
  </Tabs>
427
1127
  </PageBody>
428
1128
  </Page>