create-snapify-app 0.0.0-stage → 1.1.1

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 (614) hide show
  1. package/LICENSE.md +24 -0
  2. package/README.md +55 -2
  3. package/bin/create-snapify-app.mjs +174 -0
  4. package/lib/args.mjs +59 -0
  5. package/lib/copy.mjs +44 -0
  6. package/lib/env.mjs +84 -0
  7. package/lib/prompt.mjs +63 -0
  8. package/package.json +35 -4
  9. package/template/.env.example +79 -0
  10. package/template/.github/workflows/ci.yml +23 -0
  11. package/template/.github/workflows/deploy.yml +26 -0
  12. package/template/.github/workflows/ping.yml +8 -0
  13. package/template/.github/workflows/release.yml +67 -0
  14. package/template/.snapify-install.json +5 -0
  15. package/template/CHANGELOG.md +28 -0
  16. package/template/LICENSE +14 -0
  17. package/template/README.md +287 -0
  18. package/template/administrator.html +23 -0
  19. package/template/apps/client/src/App.tsx +765 -0
  20. package/template/apps/client/src/auth.css +400 -0
  21. package/template/apps/client/src/lib/api.ts +22 -0
  22. package/template/apps/client/src/main.tsx +20 -0
  23. package/template/apps/community/src/App.tsx +592 -0
  24. package/template/apps/community/src/main.tsx +20 -0
  25. package/template/apps/community/src/styles.css +24 -0
  26. package/template/apps/developers/src/App.tsx +643 -0
  27. package/template/apps/developers/src/main.tsx +17 -0
  28. package/template/apps/developers/src/pages.tsx +1587 -0
  29. package/template/apps/developers/src/styles.css +98 -0
  30. package/template/apps/developers/src/ui.tsx +179 -0
  31. package/template/apps/landing/src/App.tsx +598 -0
  32. package/template/apps/landing/src/LegalPages.tsx +158 -0
  33. package/template/apps/landing/src/OrderPage.tsx +381 -0
  34. package/template/apps/landing/src/SeoPages.tsx +923 -0
  35. package/template/apps/landing/src/copy.ts +195 -0
  36. package/template/apps/landing/src/main.tsx +20 -0
  37. package/template/apps/landing/src/styles.css +803 -0
  38. package/template/apps/landing/src/types.ts +19 -0
  39. package/template/apps/marketplace/src/App.tsx +1229 -0
  40. package/template/apps/marketplace/src/developer/DeveloperApp.tsx +1266 -0
  41. package/template/apps/marketplace/src/main.tsx +20 -0
  42. package/template/apps/marketplace/src/styles.css +1001 -0
  43. package/template/apps/shared/AuthContext.tsx +65 -0
  44. package/template/apps/shared/Logo.tsx +33 -0
  45. package/template/apps/shared/api.ts +47 -0
  46. package/template/apps/shared/ui.tsx +128 -0
  47. package/template/apps/superadmin/src/App.tsx +1264 -0
  48. package/template/apps/superadmin/src/lib/api.ts +20 -0
  49. package/template/apps/superadmin/src/lib/hooks.ts +20 -0
  50. package/template/apps/superadmin/src/main.tsx +16 -0
  51. package/template/apps/superadmin/src/pages/ClientsPage.tsx +185 -0
  52. package/template/apps/superadmin/src/pages/InvoicesPage.tsx +157 -0
  53. package/template/apps/superadmin/src/pages/NewsletterPage.tsx +212 -0
  54. package/template/apps/superadmin/src/pages/OrdersPage.tsx +195 -0
  55. package/template/apps/superadmin/src/pages/PaymentsPage.tsx +150 -0
  56. package/template/apps/superadmin/src/pages/PayoutsPage.tsx +133 -0
  57. package/template/apps/superadmin/src/pages/PublishersPage.tsx +151 -0
  58. package/template/apps/superadmin/src/pages/SeoControlPage.tsx +692 -0
  59. package/template/apps/superadmin/src/pages/SettingsPage.tsx +199 -0
  60. package/template/apps/superadmin/src/pages/SystemPage.tsx +109 -0
  61. package/template/apps/superadmin/src/pages/TicketsPage.tsx +201 -0
  62. package/template/apps/superadmin/src/pages/UsersPage.tsx +167 -0
  63. package/template/client.html +21 -0
  64. package/template/community/hamdiayari/akhbar/README.md +23 -0
  65. package/template/community/hamdiayari/akhbar/screenshot.jpg +0 -0
  66. package/template/community/hamdiayari/akhbar/snapify.theme.json +76 -0
  67. package/template/community/hamdiayari/akhbar/src/Brand.tsx +26 -0
  68. package/template/community/hamdiayari/akhbar/src/Footer.tsx +182 -0
  69. package/template/community/hamdiayari/akhbar/src/Header.tsx +191 -0
  70. package/template/community/hamdiayari/akhbar/src/Layout.tsx +49 -0
  71. package/template/community/hamdiayari/akhbar/src/Social.tsx +33 -0
  72. package/template/community/hamdiayari/akhbar/src/Ticker.tsx +86 -0
  73. package/template/community/hamdiayari/akhbar/src/Views.tsx +285 -0
  74. package/template/community/hamdiayari/akhbar/src/blocks/CollectionBlocks.tsx +672 -0
  75. package/template/community/hamdiayari/akhbar/src/blocks/ContentBlocks.tsx +305 -0
  76. package/template/community/hamdiayari/akhbar/src/blocks/ConversionBlocks.tsx +569 -0
  77. package/template/community/hamdiayari/akhbar/src/blocks/HeaderBlocks.tsx +287 -0
  78. package/template/community/hamdiayari/akhbar/src/index.tsx +51 -0
  79. package/template/community/hamdiayari/akhbar/src/kit.tsx +357 -0
  80. package/template/community/hamdiayari/akhbar/src/site.ts +96 -0
  81. package/template/community/hamdiayari/akhbar/src/styles.css +566 -0
  82. package/template/community/hamdiayari/akhbar-actualites/README.md +28 -0
  83. package/template/community/hamdiayari/akhbar-actualites/screenshot.jpg +0 -0
  84. package/template/community/hamdiayari/akhbar-actualites/snapify.module.json +107 -0
  85. package/template/community/hamdiayari/akhbar-actualites/src/admin/AudiencePage.tsx +185 -0
  86. package/template/community/hamdiayari/akhbar-actualites/src/admin/index.tsx +11 -0
  87. package/template/community/hamdiayari/akhbar-actualites/src/public/Archive.tsx +142 -0
  88. package/template/community/hamdiayari/akhbar-actualites/src/public/cards.tsx +152 -0
  89. package/template/community/hamdiayari/akhbar-actualites/src/public/index.tsx +71 -0
  90. package/template/community/hamdiayari/akhbar-actualites/src/public/posts.ts +103 -0
  91. package/template/community/hamdiayari/akhbar-actualites/src/public/styles.css +568 -0
  92. package/template/community/hamdiayari/akhbar-actualites/src/public/widgets.tsx +226 -0
  93. package/template/community/hamdiayari/akhbar-actualites/src/server.ts +119 -0
  94. package/template/community/hamdiayari/akhbar-actualites/src/types.ts +25 -0
  95. package/template/community/hamdiayari/akhbar-urgent/README.md +36 -0
  96. package/template/community/hamdiayari/akhbar-urgent/screenshot.jpg +0 -0
  97. package/template/community/hamdiayari/akhbar-urgent/snapify.module.json +64 -0
  98. package/template/community/hamdiayari/akhbar-urgent/src/admin/FlashesPage.tsx +229 -0
  99. package/template/community/hamdiayari/akhbar-urgent/src/admin/index.tsx +11 -0
  100. package/template/community/hamdiayari/akhbar-urgent/src/public/Ticker.tsx +81 -0
  101. package/template/community/hamdiayari/akhbar-urgent/src/public/index.tsx +63 -0
  102. package/template/community/hamdiayari/akhbar-urgent/src/public/styles.css +142 -0
  103. package/template/community/hamdiayari/akhbar-urgent/src/server.ts +101 -0
  104. package/template/community/hamdiayari/akhbar-urgent/src/types.ts +35 -0
  105. package/template/community.html +21 -0
  106. package/template/developers.html +24 -0
  107. package/template/docker-compose.db.yml +24 -0
  108. package/template/docs/BACKOFFICE_SNAPIFY.md +130 -0
  109. package/template/docs/CI_CD.md +66 -0
  110. package/template/docs/MODULES.md +346 -0
  111. package/template/docs/PLATFORM_DEPLOY.md +54 -0
  112. package/template/docs/logo.svg +10 -0
  113. package/template/drizzle/0000_init.sql +147 -0
  114. package/template/drizzle/0001_page_project.sql +5 -0
  115. package/template/drizzle/0002_modules.sql +23 -0
  116. package/template/drizzle/0003_themes.sql +4 -0
  117. package/template/drizzle/0004_security.sql +82 -0
  118. package/template/drizzle/0005_module_records.sql +16 -0
  119. package/template/drizzle/0006_avatars.sql +4 -0
  120. package/template/drizzle/0007_platform.sql +264 -0
  121. package/template/drizzle/0008_mp_validation.sql +3 -0
  122. package/template/drizzle/0009_saas_ops.sql +77 -0
  123. package/template/drizzle/0010_cms_chrome.sql +13 -0
  124. package/template/drizzle/0011_client_portal.sql +26 -0
  125. package/template/drizzle/meta/0000_snapshot.json +998 -0
  126. package/template/drizzle/meta/0001_snapshot.json +1004 -0
  127. package/template/drizzle/meta/_journal.json +90 -0
  128. package/template/drizzle.config.ts +16 -0
  129. package/template/ecosystem.snapify.cjs +24 -0
  130. package/template/for.html +1 -0
  131. package/template/index.html +29 -0
  132. package/template/landing.html +37 -0
  133. package/template/marketplace/catalog.json +373 -0
  134. package/template/marketplace/gallery/hamdiayari.preveris/01-home.jpg +0 -0
  135. package/template/marketplace/gallery/hamdiayari.recaptcha/01-reglages.jpg +0 -0
  136. package/template/marketplace/gallery/hamdiayari.recaptcha/02-logo.jpg +0 -0
  137. package/template/marketplace/gallery/novalabs.ai-articles/01-generer.jpg +0 -0
  138. package/template/marketplace/gallery/novalabs.ai-articles/02-fournisseur-ia.jpg +0 -0
  139. package/template/marketplace/gallery/novalabs.calendar/01-agenda-site.jpg +0 -0
  140. package/template/marketplace/gallery/novalabs.calendar/02-evenements-admin.jpg +0 -0
  141. package/template/marketplace/gallery/novalabs.calendar/03-reglages.jpg +0 -0
  142. package/template/marketplace/screenshots/hamdiayari.preveris-1.0.6.jpg +0 -0
  143. package/template/marketplace/screenshots/hamdiayari.recaptcha-1.0.0.jpg +0 -0
  144. package/template/marketplace/screenshots/hamdiayari.wp-seo-migrator-1.1.1.jpg +0 -0
  145. package/template/marketplace/screenshots/novalabs.ai-articles-1.0.1.jpg +0 -0
  146. package/template/marketplace/screenshots/novalabs.ai-chat-1.0.1.jpg +0 -0
  147. package/template/marketplace/screenshots/novalabs.calendar-1.0.1.jpg +0 -0
  148. package/template/marketplace/screenshots/novalabs.cart-1.0.0.jpg +0 -0
  149. package/template/marketplace/screenshots/novalabs.checkout-1.0.0.jpg +0 -0
  150. package/template/marketplace/screenshots/novalabs.customers-1.1.0.jpg +0 -0
  151. package/template/marketplace/screenshots/novalabs.dogga-1.0.0.jpg +0 -0
  152. package/template/marketplace/screenshots/novalabs.mahdia-1.0.0.jpg +0 -0
  153. package/template/marketplace/screenshots/novalabs.shop-1.0.0.jpg +0 -0
  154. package/template/marketplace/screenshots/novalabs.sidibou-1.0.0.jpg +0 -0
  155. package/template/marketplace/screenshots/novalabs.whatsapp-1.0.1.jpg +0 -0
  156. package/template/marketplace.html +21 -0
  157. package/template/metadata.json +7 -0
  158. package/template/modules/ai-articles/screenshot.jpg +0 -0
  159. package/template/modules/ai-articles/snapify.module.json +74 -0
  160. package/template/modules/ai-articles/src/admin/ArticlesPage.tsx +162 -0
  161. package/template/modules/ai-articles/src/admin/GeneratePage.tsx +118 -0
  162. package/template/modules/ai-articles/src/admin/index.tsx +12 -0
  163. package/template/modules/ai-articles/src/public/index.tsx +119 -0
  164. package/template/modules/ai-articles/src/server.ts +287 -0
  165. package/template/modules/ai-articles/src/types.ts +30 -0
  166. package/template/modules/ai-chat/screenshot.jpg +0 -0
  167. package/template/modules/ai-chat/snapify.module.json +217 -0
  168. package/template/modules/ai-chat/src/public.tsx +198 -0
  169. package/template/modules/ai-chat/src/server.ts +142 -0
  170. package/template/modules/ai-theme-studio/README.md +15 -0
  171. package/template/modules/ai-theme-studio/screenshot.jpg +0 -0
  172. package/template/modules/ai-theme-studio/snapify.module.json +175 -0
  173. package/template/modules/ai-theme-studio/src/admin/HistoryPage.tsx +91 -0
  174. package/template/modules/ai-theme-studio/src/admin/StudioPage.tsx +414 -0
  175. package/template/modules/ai-theme-studio/src/admin/index.tsx +11 -0
  176. package/template/modules/ai-theme-studio/src/lib/images.ts +240 -0
  177. package/template/modules/ai-theme-studio/src/lib/llm.ts +123 -0
  178. package/template/modules/ai-theme-studio/src/lib/normalize.ts +714 -0
  179. package/template/modules/ai-theme-studio/src/lib/prompts.ts +198 -0
  180. package/template/modules/ai-theme-studio/src/server.ts +137 -0
  181. package/template/modules/ai-theme-studio/src/types.ts +112 -0
  182. package/template/modules/calendar/screenshot.jpg +0 -0
  183. package/template/modules/calendar/snapify.module.json +45 -0
  184. package/template/modules/calendar/src/admin/EventsPage.tsx +217 -0
  185. package/template/modules/calendar/src/admin/index.tsx +11 -0
  186. package/template/modules/calendar/src/public/index.tsx +231 -0
  187. package/template/modules/calendar/src/server.ts +143 -0
  188. package/template/modules/calendar/src/types.ts +25 -0
  189. package/template/modules/cart/screenshot.jpg +0 -0
  190. package/template/modules/cart/snapify.module.json +90 -0
  191. package/template/modules/cart/src/admin/index.tsx +265 -0
  192. package/template/modules/cart/src/public/CartPage.tsx +198 -0
  193. package/template/modules/cart/src/public/index.tsx +261 -0
  194. package/template/modules/cart/src/public/useQuote.ts +67 -0
  195. package/template/modules/cart/src/server.ts +148 -0
  196. package/template/modules/cart/src/types.ts +31 -0
  197. package/template/modules/checkout/screenshot.jpg +0 -0
  198. package/template/modules/checkout/snapify.module.json +264 -0
  199. package/template/modules/checkout/src/admin/OrderDetailPage.tsx +304 -0
  200. package/template/modules/checkout/src/admin/badges.tsx +28 -0
  201. package/template/modules/checkout/src/admin/index.tsx +236 -0
  202. package/template/modules/checkout/src/public/CheckoutPage.tsx +461 -0
  203. package/template/modules/checkout/src/public/OrderPages.tsx +153 -0
  204. package/template/modules/checkout/src/public/index.tsx +23 -0
  205. package/template/modules/checkout/src/server.ts +383 -0
  206. package/template/modules/checkout/src/types.ts +70 -0
  207. package/template/modules/customers/screenshot.jpg +0 -0
  208. package/template/modules/customers/snapify.module.json +115 -0
  209. package/template/modules/customers/src/admin/CustomerDetailPage.tsx +274 -0
  210. package/template/modules/customers/src/admin/index.tsx +194 -0
  211. package/template/modules/customers/src/public/AccountPages.tsx +470 -0
  212. package/template/modules/customers/src/public/AddressForm.tsx +115 -0
  213. package/template/modules/customers/src/public/AuthPages.tsx +349 -0
  214. package/template/modules/customers/src/public/Avatar.tsx +29 -0
  215. package/template/modules/customers/src/public/ProfileSection.tsx +291 -0
  216. package/template/modules/customers/src/public/index.tsx +137 -0
  217. package/template/modules/customers/src/public/session.ts +51 -0
  218. package/template/modules/customers/src/server.ts +421 -0
  219. package/template/modules/customers/src/types.ts +53 -0
  220. package/template/modules/recaptcha/logo.jpg +0 -0
  221. package/template/modules/recaptcha/screenshot.jpg +0 -0
  222. package/template/modules/recaptcha/snapify.module.json +99 -0
  223. package/template/modules/recaptcha/src/public/index.tsx +94 -0
  224. package/template/modules/recaptcha/src/server.ts +65 -0
  225. package/template/modules/recaptcha/src/types.ts +18 -0
  226. package/template/modules/shop/screenshot.jpg +0 -0
  227. package/template/modules/shop/snapify.module.json +340 -0
  228. package/template/modules/shop/src/admin/CategoriesPage.tsx +224 -0
  229. package/template/modules/shop/src/admin/ProductEditor.tsx +313 -0
  230. package/template/modules/shop/src/admin/ProductsPage.tsx +307 -0
  231. package/template/modules/shop/src/admin/index.tsx +14 -0
  232. package/template/modules/shop/src/public/Catalog.tsx +244 -0
  233. package/template/modules/shop/src/public/NotFound.tsx +22 -0
  234. package/template/modules/shop/src/public/ProductPage.tsx +195 -0
  235. package/template/modules/shop/src/public/index.tsx +134 -0
  236. package/template/modules/shop/src/public/useFetch.ts +26 -0
  237. package/template/modules/shop/src/server.ts +493 -0
  238. package/template/modules/shop/src/shared.ts +41 -0
  239. package/template/modules/shop/src/types.ts +60 -0
  240. package/template/modules/whatsapp/screenshot.jpg +0 -0
  241. package/template/modules/whatsapp/snapify.module.json +121 -0
  242. package/template/modules/whatsapp/src/public.tsx +119 -0
  243. package/template/modules/wp-seo-migrator/ARCHITECTURE.md +113 -0
  244. package/template/modules/wp-seo-migrator/README.md +21 -0
  245. package/template/modules/wp-seo-migrator/fixtures/aioseo-sample.csv +3 -0
  246. package/template/modules/wp-seo-migrator/fixtures/sample-exporter.json +39 -0
  247. package/template/modules/wp-seo-migrator/fixtures/sample-page.html +12 -0
  248. package/template/modules/wp-seo-migrator/fixtures/sample.htaccess +3 -0
  249. package/template/modules/wp-seo-migrator/screenshot.jpg +0 -0
  250. package/template/modules/wp-seo-migrator/snapify.module.json +87 -0
  251. package/template/modules/wp-seo-migrator/src/admin/HistoryPage.tsx +64 -0
  252. package/template/modules/wp-seo-migrator/src/admin/WizardPage.tsx +788 -0
  253. package/template/modules/wp-seo-migrator/src/admin/i18n.ts +156 -0
  254. package/template/modules/wp-seo-migrator/src/admin/index.tsx +11 -0
  255. package/template/modules/wp-seo-migrator/src/connectors/CUSTOM_CONNECTOR.md +31 -0
  256. package/template/modules/wp-seo-migrator/src/connectors/csvSeoFactory.ts +106 -0
  257. package/template/modules/wp-seo-migrator/src/connectors/exporter.ts +96 -0
  258. package/template/modules/wp-seo-migrator/src/connectors/gscCsv.ts +53 -0
  259. package/template/modules/wp-seo-migrator/src/connectors/htaccess.ts +65 -0
  260. package/template/modules/wp-seo-migrator/src/connectors/htmlCrawler.ts +90 -0
  261. package/template/modules/wp-seo-migrator/src/connectors/index.ts +113 -0
  262. package/template/modules/wp-seo-migrator/src/connectors/pluginCsv.ts +77 -0
  263. package/template/modules/wp-seo-migrator/src/connectors/rankMathCsv.ts +77 -0
  264. package/template/modules/wp-seo-migrator/src/connectors/redirection.ts +62 -0
  265. package/template/modules/wp-seo-migrator/src/connectors/sitemap.ts +44 -0
  266. package/template/modules/wp-seo-migrator/src/connectors/utils.ts +111 -0
  267. package/template/modules/wp-seo-migrator/src/connectors/wxr.ts +103 -0
  268. package/template/modules/wp-seo-migrator/src/connectors/yoastCsv.ts +56 -0
  269. package/template/modules/wp-seo-migrator/src/pipeline/analyze.ts +158 -0
  270. package/template/modules/wp-seo-migrator/src/pipeline/htmlParse.ts +58 -0
  271. package/template/modules/wp-seo-migrator/src/pipeline/remoteAuto.ts +328 -0
  272. package/template/modules/wp-seo-migrator/src/server.ts +492 -0
  273. package/template/modules/wp-seo-migrator/src/types.ts +151 -0
  274. package/template/modules/wp-seo-migrator/tests/connectors-extra.test.ts +39 -0
  275. package/template/modules/wp-seo-migrator/tests/merge.test.ts +25 -0
  276. package/template/package-lock.json +6016 -0
  277. package/template/package.json +74 -0
  278. package/template/public/images/photos/caravane-chameaux-desert.webp +0 -0
  279. package/template/public/images/photos/hammamet-medina-plage.jpg +0 -0
  280. package/template/public/images/photos/maison-campagne-collines.webp +0 -0
  281. package/template/public/images/photos/maisons-troglodytes-desert.jpg +0 -0
  282. package/template/public/images/photos/panorama-ville.jpg +0 -0
  283. package/template/public/images/photos/preveris-accessibilite-erp.jpg +0 -0
  284. package/template/public/images/photos/preveris-collectivites.jpg +0 -0
  285. package/template/public/images/photos/preveris-etudes-plans.jpg +0 -0
  286. package/template/public/images/photos/preveris-hero-erp.jpg +0 -0
  287. package/template/public/images/photos/preveris-ssi-incendie.jpg +0 -0
  288. package/template/public/images/photos/preveris-suivi-hotel.jpg +0 -0
  289. package/template/public/images/photos/ruelle-bleue-medina.jpg +0 -0
  290. package/template/public/images/photos/sidi-bou-said-port.webp +0 -0
  291. package/template/public/images/photos/terrasse-blanche-mer-voilier.jpg +0 -0
  292. package/template/public/landing/hero-ai-cms.jpg +0 -0
  293. package/template/public/landing/shot-dogga.jpg +0 -0
  294. package/template/public/landing/shot-mahdia.jpg +0 -0
  295. package/template/public/landing/shot-sidibou.jpg +0 -0
  296. package/template/public/llms.txt +35 -0
  297. package/template/public/logo.svg +10 -0
  298. package/template/scripts/app-pack.ts +155 -0
  299. package/template/scripts/app-publish.ts +123 -0
  300. package/template/scripts/apply-ecommerce-home.ts +196 -0
  301. package/template/scripts/auto-deploy-poll.sh +13 -0
  302. package/template/scripts/db-backup.sh +16 -0
  303. package/template/scripts/db-restore.sh +11 -0
  304. package/template/scripts/deploy.sh +34 -0
  305. package/template/scripts/github-webhook-deploy.sh +7 -0
  306. package/template/scripts/install-new-modules.ts +96 -0
  307. package/template/scripts/refresh-demo-baseline.ts +9 -0
  308. package/template/scripts/reinstall-marketplace-packs.ts +51 -0
  309. package/template/scripts/restore-classic-home.ts +13 -0
  310. package/template/scripts/sdk-pack.ts +289 -0
  311. package/template/scripts/sdk-publish.ts +75 -0
  312. package/template/scripts/seed-demo-products.ts +120 -0
  313. package/template/scripts/seed-theme-content-packs.ts +42 -0
  314. package/template/scripts/snapify-module.ts +207 -0
  315. package/template/scripts/test-theme-switch.ts +24 -0
  316. package/template/sdk/admin.css +18 -0
  317. package/template/sdk/admin.ts +95 -0
  318. package/template/sdk/build.ts +249 -0
  319. package/template/sdk/commerce.ts +512 -0
  320. package/template/sdk/content.ts +76 -0
  321. package/template/sdk/icons.ts +101 -0
  322. package/template/sdk/image.ts +60 -0
  323. package/template/sdk/index.ts +500 -0
  324. package/template/sdk/package.ts +298 -0
  325. package/template/sdk/recaptcha.tsx +122 -0
  326. package/template/sdk/settings.ts +84 -0
  327. package/template/sdk/theme.css +29 -0
  328. package/template/sdk/theme.ts +100 -0
  329. package/template/sdk/ui/commerce.tsx +385 -0
  330. package/template/sdk/ui/orders.tsx +136 -0
  331. package/template/sdk-kit/CHANGELOG.md +17 -0
  332. package/template/sdk-kit/LICENSE.md +47 -0
  333. package/template/sdk-kit/README.md +81 -0
  334. package/template/sdk-kit/cli.ts +339 -0
  335. package/template/sdk-kit/create-snapify/README.md +28 -0
  336. package/template/sdk-kit/create-snapify/create-snapify.mjs +11 -0
  337. package/template/sdk-kit/create-snapify/package.template.json +16 -0
  338. package/template/sdk-kit/package.template.json +95 -0
  339. package/template/sdk-kit/templates/common/README.md.tpl +27 -0
  340. package/template/sdk-kit/templates/common/_gitignore +3 -0
  341. package/template/sdk-kit/templates/common/package.json.tpl +19 -0
  342. package/template/sdk-kit/templates/common/src/snapify-env.d.ts +2 -0
  343. package/template/sdk-kit/templates/common/tsconfig.json +15 -0
  344. package/template/sdk-kit/templates/module/snapify.module.json.tpl +33 -0
  345. package/template/sdk-kit/templates/module/src/admin.tsx +53 -0
  346. package/template/sdk-kit/templates/module/src/public.tsx +49 -0
  347. package/template/sdk-kit/templates/module/src/server.ts +30 -0
  348. package/template/sdk-kit/templates/module/src/types.ts +19 -0
  349. package/template/sdk-kit/templates/theme/snapify.theme.json.tpl +22 -0
  350. package/template/sdk-kit/templates/theme/src/Footer.tsx +40 -0
  351. package/template/sdk-kit/templates/theme/src/Header.tsx +47 -0
  352. package/template/sdk-kit/templates/theme/src/Views.tsx +36 -0
  353. package/template/sdk-kit/templates/theme/src/blocks.tsx +93 -0
  354. package/template/sdk-kit/templates/theme/src/index.tsx +30 -0
  355. package/template/sdk-kit/templates/theme/src/site.tsx +59 -0
  356. package/template/sdk-kit/templates/theme/src/styles.css +71 -0
  357. package/template/server/client/routes.ts +252 -0
  358. package/template/server/cms/analyticsStore.ts +180 -0
  359. package/template/server/cms/auth.ts +155 -0
  360. package/template/server/cms/blockCatalog.ts +342 -0
  361. package/template/server/cms/blocks.ts +521 -0
  362. package/template/server/cms/contentMigration.ts +1119 -0
  363. package/template/server/cms/defaults.ts +303 -0
  364. package/template/server/cms/permissions.ts +154 -0
  365. package/template/server/cms/restoreClassicHome.ts +183 -0
  366. package/template/server/cms/revisions.ts +99 -0
  367. package/template/server/cms/scheduler.ts +78 -0
  368. package/template/server/cms/seed.ts +72 -0
  369. package/template/server/cms/seoAnalyzer.ts +213 -0
  370. package/template/server/cms/store.ts +122 -0
  371. package/template/server/cms/themeContent.ts +1386 -0
  372. package/template/server/cms/themePacks/sidibou-branding.json +1 -0
  373. package/template/server/cms/themePacks/sidibou-home.json +1 -0
  374. package/template/server/cms/themePacks/sidibou-menus.json +1 -0
  375. package/template/server/cms/types.ts +432 -0
  376. package/template/server/community/routes.ts +410 -0
  377. package/template/server/db/client.ts +43 -0
  378. package/template/server/db/repository.ts +389 -0
  379. package/template/server/db/schema.ts +726 -0
  380. package/template/server/demo/cmsReset.ts +196 -0
  381. package/template/server/developers/sdk.ts +85 -0
  382. package/template/server/geoip.ts +15 -0
  383. package/template/server/http.ts +53 -0
  384. package/template/server/mail.ts +216 -0
  385. package/template/server/marketplace/catalog.ts +74 -0
  386. package/template/server/marketplace/packageValidate.ts +475 -0
  387. package/template/server/marketplace/payments.ts +193 -0
  388. package/template/server/marketplace/routes.ts +850 -0
  389. package/template/server/marketplace/signing.ts +116 -0
  390. package/template/server/marketplace/syncOfficial.ts +282 -0
  391. package/template/server/media/images.ts +63 -0
  392. package/template/server/middleware/adminAuth.ts +134 -0
  393. package/template/server/modules/legacy.ts +44 -0
  394. package/template/server/modules/manager.ts +1122 -0
  395. package/template/server/modules/marketplace.ts +178 -0
  396. package/template/server/modules/outbound.ts +178 -0
  397. package/template/server/modules/purchases.ts +149 -0
  398. package/template/server/modules/records.ts +168 -0
  399. package/template/server/modules/themeHtml.ts +32 -0
  400. package/template/server/modules/trust.ts +16 -0
  401. package/template/server/modules/types.ts +147 -0
  402. package/template/server/platform/auth.ts +223 -0
  403. package/template/server/platform/clientWelcome.ts +168 -0
  404. package/template/server/platform/clients.ts +139 -0
  405. package/template/server/platform/dailyImprove.ts +356 -0
  406. package/template/server/platform/flouci.ts +126 -0
  407. package/template/server/platform/host.ts +69 -0
  408. package/template/server/platform/hostingProviders.ts +143 -0
  409. package/template/server/platform/licenseOrders.ts +291 -0
  410. package/template/server/platform/money.ts +42 -0
  411. package/template/server/platform/passwords.ts +41 -0
  412. package/template/server/platform/region.ts +86 -0
  413. package/template/server/platform/routes.ts +225 -0
  414. package/template/server/platform/seed.ts +82 -0
  415. package/template/server/platform/settings.ts +72 -0
  416. package/template/server/routes/adminAccount.ts +403 -0
  417. package/template/server/routes/adminCms.ts +808 -0
  418. package/template/server/routes/adminSystem.ts +243 -0
  419. package/template/server/routes/adminUsers.ts +449 -0
  420. package/template/server/routes/googleOAuth.ts +327 -0
  421. package/template/server/routes/modules.ts +323 -0
  422. package/template/server/routes/publicCms.ts +313 -0
  423. package/template/server/routes/seoPublic.ts +158 -0
  424. package/template/server/security/accounts.ts +121 -0
  425. package/template/server/security/audit.ts +143 -0
  426. package/template/server/security/authTokens.ts +74 -0
  427. package/template/server/security/ip.ts +68 -0
  428. package/template/server/security/rateLimit.ts +85 -0
  429. package/template/server/security/retention.ts +46 -0
  430. package/template/server/security/secrets.ts +44 -0
  431. package/template/server/security/sessions.ts +140 -0
  432. package/template/server/security/totp.ts +101 -0
  433. package/template/server/superadmin/analytics.ts +109 -0
  434. package/template/server/superadmin/auth.ts +92 -0
  435. package/template/server/superadmin/backlinksCatalog.ts +210 -0
  436. package/template/server/superadmin/frSeoContent.ts +165 -0
  437. package/template/server/superadmin/landingSeo.ts +675 -0
  438. package/template/server/superadmin/ops.ts +238 -0
  439. package/template/server/superadmin/routes.ts +1095 -0
  440. package/template/server/superadmin/tnSeoContent.ts +130 -0
  441. package/template/server.ts +522 -0
  442. package/template/src/App.tsx +263 -0
  443. package/template/src/admin/AdminApp.tsx +162 -0
  444. package/template/src/admin/account/AuthLayout.tsx +57 -0
  445. package/template/src/admin/account/SecurityForms.tsx +276 -0
  446. package/template/src/admin/admin.css +127 -0
  447. package/template/src/admin/context/AuthContext.tsx +147 -0
  448. package/template/src/admin/context/SiteContext.tsx +50 -0
  449. package/template/src/admin/editor/BlockLibrary.tsx +173 -0
  450. package/template/src/admin/editor/BlocksPanel.tsx +396 -0
  451. package/template/src/admin/editor/EditorBits.tsx +106 -0
  452. package/template/src/admin/editor/PreviewFrame.tsx +145 -0
  453. package/template/src/admin/editor/RichTextEditor.tsx +276 -0
  454. package/template/src/admin/editor/SeoPanel.tsx +349 -0
  455. package/template/src/admin/editor/StyleEditor.tsx +114 -0
  456. package/template/src/admin/editor/blockSchemas.ts +699 -0
  457. package/template/src/admin/editor/fields.tsx +486 -0
  458. package/template/src/admin/layout/AdminShell.tsx +418 -0
  459. package/template/src/admin/layout/nav.ts +95 -0
  460. package/template/src/admin/lib/api.ts +408 -0
  461. package/template/src/admin/lib/audit.ts +141 -0
  462. package/template/src/admin/lib/format.ts +84 -0
  463. package/template/src/admin/lib/hooks.ts +125 -0
  464. package/template/src/admin/main.tsx +16 -0
  465. package/template/src/admin/modules/InstallDialog.tsx +160 -0
  466. package/template/src/admin/modules/SettingsForm.tsx +145 -0
  467. package/template/src/admin/modules/kit.tsx +127 -0
  468. package/template/src/admin/modules/labels.tsx +226 -0
  469. package/template/src/admin/pages/AccountSetupPage.tsx +62 -0
  470. package/template/src/admin/pages/AnalyticsPage.tsx +206 -0
  471. package/template/src/admin/pages/BrandingPage.tsx +154 -0
  472. package/template/src/admin/pages/DashboardPage.tsx +239 -0
  473. package/template/src/admin/pages/EngagementPage.tsx +168 -0
  474. package/template/src/admin/pages/FormsPage.tsx +435 -0
  475. package/template/src/admin/pages/GeneralPage.tsx +120 -0
  476. package/template/src/admin/pages/LoginPage.tsx +249 -0
  477. package/template/src/admin/pages/MaintenancePage.tsx +125 -0
  478. package/template/src/admin/pages/MarketplacePage.tsx +426 -0
  479. package/template/src/admin/pages/MediaPage.tsx +251 -0
  480. package/template/src/admin/pages/MenusPage.tsx +446 -0
  481. package/template/src/admin/pages/ModuleAdminPage.tsx +113 -0
  482. package/template/src/admin/pages/ModuleDetailPage.tsx +293 -0
  483. package/template/src/admin/pages/ModulesPage.tsx +135 -0
  484. package/template/src/admin/pages/NewsletterPage.tsx +193 -0
  485. package/template/src/admin/pages/PageEditorPage.tsx +322 -0
  486. package/template/src/admin/pages/PagesListPage.tsx +277 -0
  487. package/template/src/admin/pages/PostEditorPage.tsx +292 -0
  488. package/template/src/admin/pages/PostsListPage.tsx +187 -0
  489. package/template/src/admin/pages/ProfilePage.tsx +489 -0
  490. package/template/src/admin/pages/ResetPasswordPage.tsx +110 -0
  491. package/template/src/admin/pages/SeoPage.tsx +670 -0
  492. package/template/src/admin/pages/SettingsPage.tsx +189 -0
  493. package/template/src/admin/pages/ThemeCustomizePage.tsx +170 -0
  494. package/template/src/admin/pages/ThemesPage.tsx +292 -0
  495. package/template/src/admin/pages/UsersPage.tsx +57 -0
  496. package/template/src/admin/pages/WidgetsPage.tsx +200 -0
  497. package/template/src/admin/seo/GoogleSetup.tsx +529 -0
  498. package/template/src/admin/settings/EmailTab.tsx +293 -0
  499. package/template/src/admin/settings/OtherTabs.tsx +270 -0
  500. package/template/src/admin/settings/SecurityTab.tsx +172 -0
  501. package/template/src/admin/settings/controls.tsx +133 -0
  502. package/template/src/admin/ui/Pagination.tsx +170 -0
  503. package/template/src/admin/ui/SaveBar.tsx +77 -0
  504. package/template/src/admin/ui/TagInput.tsx +54 -0
  505. package/template/src/admin/ui/charts.tsx +108 -0
  506. package/template/src/admin/ui/media.tsx +265 -0
  507. package/template/src/admin/ui/overlays.tsx +239 -0
  508. package/template/src/admin/ui/primitives.tsx +544 -0
  509. package/template/src/admin/users/AuditTab.tsx +104 -0
  510. package/template/src/admin/users/RolesTab.tsx +368 -0
  511. package/template/src/admin/users/UsersTab.tsx +723 -0
  512. package/template/src/admin/users/shared.tsx +278 -0
  513. package/template/src/cms/icons.ts +7 -0
  514. package/template/src/components/CookieBanner.tsx +166 -0
  515. package/template/src/components/MaintenanceGate.tsx +66 -0
  516. package/template/src/components/SiteAnalytics.tsx +64 -0
  517. package/template/src/components/SnapifyLogo.tsx +44 -0
  518. package/template/src/components/cms/BlockRenderer.tsx +154 -0
  519. package/template/src/components/cms/WidgetArea.tsx +193 -0
  520. package/template/src/context/SiteConfigContext.tsx +130 -0
  521. package/template/src/demoBase.ts +23 -0
  522. package/template/src/index.css +90 -0
  523. package/template/src/main.tsx +16 -0
  524. package/template/src/modules/ModuleHost.tsx +197 -0
  525. package/template/src/preview.ts +63 -0
  526. package/template/src/runtime.ts +38 -0
  527. package/template/src/themes/ThemeHost.tsx +168 -0
  528. package/template/src/themes/fallback.tsx +71 -0
  529. package/template/src/types.ts +94 -0
  530. package/template/src/views/BlogPostView.tsx +68 -0
  531. package/template/src/views/CmsPageView.tsx +34 -0
  532. package/template/start-pm2.sh +3 -0
  533. package/template/superadmin.html +20 -0
  534. package/template/themes/ai-canvas/screenshot.jpg +0 -0
  535. package/template/themes/ai-canvas/snapify.theme.json +96 -0
  536. package/template/themes/ai-canvas/src/CeeSimulator.tsx +247 -0
  537. package/template/themes/ai-canvas/src/Footer.tsx +190 -0
  538. package/template/themes/ai-canvas/src/Header.tsx +324 -0
  539. package/template/themes/ai-canvas/src/Layout.tsx +56 -0
  540. package/template/themes/ai-canvas/src/NotFound.tsx +58 -0
  541. package/template/themes/ai-canvas/src/Post.tsx +26 -0
  542. package/template/themes/ai-canvas/src/SnapifyLogo.tsx +44 -0
  543. package/template/themes/ai-canvas/src/blocks/CollectionBlocks.tsx +711 -0
  544. package/template/themes/ai-canvas/src/blocks/ContentBlocks.tsx +318 -0
  545. package/template/themes/ai-canvas/src/blocks/ConversionBlocks.tsx +559 -0
  546. package/template/themes/ai-canvas/src/blocks/HeaderBlocks.tsx +341 -0
  547. package/template/themes/ai-canvas/src/index.tsx +54 -0
  548. package/template/themes/ai-canvas/src/kit.tsx +398 -0
  549. package/template/themes/ai-canvas/src/site.ts +29 -0
  550. package/template/themes/ai-canvas/src/styles.css +317 -0
  551. package/template/themes/dogga/screenshot.jpg +0 -0
  552. package/template/themes/dogga/snapify.theme.json +55 -0
  553. package/template/themes/dogga/src/Brand.tsx +41 -0
  554. package/template/themes/dogga/src/Footer.tsx +202 -0
  555. package/template/themes/dogga/src/Header.tsx +239 -0
  556. package/template/themes/dogga/src/Layout.tsx +43 -0
  557. package/template/themes/dogga/src/Views.tsx +71 -0
  558. package/template/themes/dogga/src/blocks/CollectionBlocks.tsx +674 -0
  559. package/template/themes/dogga/src/blocks/ContentBlocks.tsx +307 -0
  560. package/template/themes/dogga/src/blocks/ConversionBlocks.tsx +571 -0
  561. package/template/themes/dogga/src/blocks/HeaderBlocks.tsx +289 -0
  562. package/template/themes/dogga/src/index.tsx +53 -0
  563. package/template/themes/dogga/src/kit.tsx +362 -0
  564. package/template/themes/dogga/src/site.ts +56 -0
  565. package/template/themes/dogga/src/styles.css +396 -0
  566. package/template/themes/mahdia/screenshot.jpg +0 -0
  567. package/template/themes/mahdia/snapify.theme.json +65 -0
  568. package/template/themes/mahdia/src/Brand.tsx +39 -0
  569. package/template/themes/mahdia/src/Footer.tsx +210 -0
  570. package/template/themes/mahdia/src/Header.tsx +291 -0
  571. package/template/themes/mahdia/src/Layout.tsx +44 -0
  572. package/template/themes/mahdia/src/Views.tsx +79 -0
  573. package/template/themes/mahdia/src/blocks/CollectionBlocks.tsx +674 -0
  574. package/template/themes/mahdia/src/blocks/ContentBlocks.tsx +307 -0
  575. package/template/themes/mahdia/src/blocks/ConversionBlocks.tsx +571 -0
  576. package/template/themes/mahdia/src/blocks/HeaderBlocks.tsx +289 -0
  577. package/template/themes/mahdia/src/index.tsx +53 -0
  578. package/template/themes/mahdia/src/kit.tsx +362 -0
  579. package/template/themes/mahdia/src/site.ts +75 -0
  580. package/template/themes/mahdia/src/styles.css +446 -0
  581. package/template/themes/preveris/screenshot.jpg +0 -0
  582. package/template/themes/preveris/snapify.theme.json +126 -0
  583. package/template/themes/preveris/src/Brand.tsx +44 -0
  584. package/template/themes/preveris/src/Footer.tsx +202 -0
  585. package/template/themes/preveris/src/Header.tsx +239 -0
  586. package/template/themes/preveris/src/Layout.tsx +42 -0
  587. package/template/themes/preveris/src/Views.tsx +71 -0
  588. package/template/themes/preveris/src/blocks/CollectionBlocks.tsx +678 -0
  589. package/template/themes/preveris/src/blocks/ContentBlocks.tsx +307 -0
  590. package/template/themes/preveris/src/blocks/ConversionBlocks.tsx +571 -0
  591. package/template/themes/preveris/src/blocks/HeaderBlocks.tsx +299 -0
  592. package/template/themes/preveris/src/index.tsx +53 -0
  593. package/template/themes/preveris/src/kit.tsx +365 -0
  594. package/template/themes/preveris/src/site.ts +79 -0
  595. package/template/themes/preveris/src/styles.css +446 -0
  596. package/template/themes/sidibou/screenshot.jpg +0 -0
  597. package/template/themes/sidibou/snapify.theme.json +21 -0
  598. package/template/themes/sidibou/src/CeeSimulator.tsx +247 -0
  599. package/template/themes/sidibou/src/Footer.tsx +203 -0
  600. package/template/themes/sidibou/src/Header.tsx +324 -0
  601. package/template/themes/sidibou/src/Layout.tsx +53 -0
  602. package/template/themes/sidibou/src/NotFound.tsx +58 -0
  603. package/template/themes/sidibou/src/Post.tsx +26 -0
  604. package/template/themes/sidibou/src/SnapifyLogo.tsx +44 -0
  605. package/template/themes/sidibou/src/blocks/CollectionBlocks.tsx +711 -0
  606. package/template/themes/sidibou/src/blocks/ContentBlocks.tsx +318 -0
  607. package/template/themes/sidibou/src/blocks/ConversionBlocks.tsx +559 -0
  608. package/template/themes/sidibou/src/blocks/HeaderBlocks.tsx +341 -0
  609. package/template/themes/sidibou/src/index.tsx +54 -0
  610. package/template/themes/sidibou/src/kit.tsx +398 -0
  611. package/template/themes/sidibou/src/site.ts +28 -0
  612. package/template/themes/sidibou/src/styles.css +317 -0
  613. package/template/tsconfig.json +33 -0
  614. package/template/vite.config.ts +42 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Snapify
3
+ * © 2026 NOVALABS WEB DESIGN — Tunisie
4
+ * Contact : contact@novalabs.tn · +216 24 734 647
5
+ * Tous droits réservés. Toute reproduction, modification ou diffusion sans autorisation écrite est interdite.
6
+ */
7
+ import React from 'react';
8
+ import { AlertTriangle, KeyRound, Plus, ShieldCheck } from 'lucide-react';
9
+ import { Link } from 'react-router-dom';
10
+ import { useAuth } from '../context/AuthContext';
11
+ import { TagInput } from '../ui/TagInput';
12
+ import { Alert, Button, Field, Section, Segmented, Switch } from '../ui/primitives';
13
+ import { NumberField, ipInList, isIpRule, type TabProps } from './controls';
14
+
15
+ const TWO_FACTOR: Record<'optional' | 'superadmins' | 'all', string> = {
16
+ optional: 'Chaque utilisateur choisit d’activer ou non la double authentification depuis son profil.',
17
+ superadmins: 'Les super administrateurs doivent configurer une application d’authentification à leur prochaine connexion.',
18
+ all: 'Tous les comptes doivent configurer une application d’authentification à leur prochaine connexion.',
19
+ };
20
+
21
+ export function SecurityTab({ v, set, meta }: TabProps) {
22
+ const { user, isSuper } = useAuth();
23
+ const sec = v.security;
24
+ const allowlist = sec.adminIpAllowlist;
25
+ const selfBlocked = allowlist.length > 0 && !meta.ipBypass && !ipInList(meta.clientIp, allowlist);
26
+ const twoFaAppliesToMe = sec.twoFactor === 'all' || (sec.twoFactor === 'superadmins' && isSuper);
27
+
28
+ return (
29
+ <div className="grid gap-6 xl:grid-cols-2">
30
+ <div className="space-y-6">
31
+ <Section title="Politique de mots de passe" description="S’applique aux nouveaux mots de passe : création de compte, changement et réinitialisation.">
32
+ <NumberField
33
+ path="security.passwordMinLength"
34
+ label="Longueur minimale"
35
+ suffix="caractères"
36
+ value={sec.passwordMinLength}
37
+ onChange={(passwordMinLength) => set('security', { passwordMinLength })}
38
+ hint="12 caractères ou plus sont recommandés."
39
+ />
40
+ <Switch checked={sec.requireUppercase} onChange={(requireUppercase) => set('security', { requireUppercase })} label="Au moins une majuscule" />
41
+ <Switch checked={sec.requireNumber} onChange={(requireNumber) => set('security', { requireNumber })} label="Au moins un chiffre" />
42
+ <Switch checked={sec.requireSymbol} onChange={(requireSymbol) => set('security', { requireSymbol })} label="Au moins un caractère spécial" />
43
+ <NumberField
44
+ path="security.passwordExpiryDays"
45
+ label="Renouvellement obligatoire"
46
+ suffix="jours"
47
+ value={sec.passwordExpiryDays}
48
+ onChange={(passwordExpiryDays) => set('security', { passwordExpiryDays })}
49
+ hint="0 = jamais. Passé ce délai, l’utilisateur doit choisir un nouveau mot de passe à sa connexion."
50
+ />
51
+ <p className="text-xs leading-relaxed text-slate-500">
52
+ Les mots de passe trop simples (suites courantes, partie de l’adresse e-mail) sont toujours refusés. Ils sont stockés hachés avec scrypt.
53
+ </p>
54
+ </Section>
55
+
56
+ <Section title="Sessions" description="Durée de validité des connexions à l’administration.">
57
+ <div className="grid gap-4 sm:grid-cols-2">
58
+ <NumberField path="security.sessionHours" label="Durée d’une session" suffix="heures" value={sec.sessionHours} onChange={(sessionHours) => set('security', { sessionHours })} />
59
+ <NumberField
60
+ path="security.rememberDays"
61
+ label="Avec « Rester connecté »"
62
+ suffix="jours"
63
+ value={sec.rememberDays}
64
+ onChange={(rememberDays) => set('security', { rememberDays })}
65
+ />
66
+ </div>
67
+ <NumberField
68
+ path="security.idleMinutes"
69
+ label="Déconnexion après inactivité"
70
+ suffix="minutes"
71
+ value={sec.idleMinutes}
72
+ onChange={(idleMinutes) => set('security', { idleMinutes })}
73
+ hint="0 = désactivé. Contrôlé par le serveur : une session inactive est refusée même si l’onglet reste ouvert."
74
+ />
75
+ </Section>
76
+
77
+ <Section title="Protection contre les attaques par force brute">
78
+ <div className="grid gap-4 sm:grid-cols-2">
79
+ <NumberField
80
+ path="security.maxLoginAttempts"
81
+ label="Tentatives avant blocage"
82
+ value={sec.maxLoginAttempts}
83
+ onChange={(maxLoginAttempts) => set('security', { maxLoginAttempts })}
84
+ />
85
+ <NumberField path="security.lockoutMinutes" label="Durée du blocage" suffix="minutes" value={sec.lockoutMinutes} onChange={(lockoutMinutes) => set('security', { lockoutMinutes })} />
86
+ </div>
87
+ <p className="text-xs leading-relaxed text-slate-500">
88
+ Le compteur s’applique au couple adresse IP / e-mail ; une adresse IP est bloquée au bout de cinq fois plus d’échecs, tous comptes confondus. Les codes de double
89
+ authentification sont limités de la même façon, par compte.
90
+ </p>
91
+ </Section>
92
+ </div>
93
+
94
+ <div className="space-y-6">
95
+ <Section title="Double authentification (2FA)" description="Code à usage unique généré par une application (Google Authenticator, 1Password, Authy…).">
96
+ <Segmented
97
+ value={sec.twoFactor}
98
+ onChange={(twoFactor) => set('security', { twoFactor })}
99
+ options={[
100
+ { value: 'optional', label: 'Facultative' },
101
+ { value: 'superadmins', label: 'Super administrateurs' },
102
+ { value: 'all', label: 'Tous les comptes' },
103
+ ]}
104
+ />
105
+ <p className="text-[13px] leading-relaxed text-slate-600">{TWO_FACTOR[sec.twoFactor]}</p>
106
+ {twoFaAppliesToMe && !user?.totpEnabled ? (
107
+ <Alert
108
+ tone="warning"
109
+ icon={AlertTriangle}
110
+ title="Activez d’abord la 2FA sur votre compte"
111
+ action={
112
+ <Link to="/profile?tab=security">
113
+ <Button size="sm" icon={KeyRound}>
114
+ Configurer
115
+ </Button>
116
+ </Link>
117
+ }
118
+ >
119
+ Cette règle vous concerne : l’enregistrement sera refusé tant que votre propre compte n’est pas protégé.
120
+ </Alert>
121
+ ) : null}
122
+ </Section>
123
+
124
+ <Section title="Accès à l’administration par adresse IP" description="Si la liste n’est pas vide, seules ces adresses (ou plages CIDR) peuvent se connecter et utiliser l’administration.">
125
+ <Field label="Adresses autorisées" hint="Exemples : 196.203.12.4, 10.0.0.0/8, 2001:db8::/32. Liste vide = aucune restriction.">
126
+ <TagInput value={allowlist} onChange={(adminIpAllowlist) => set('security', { adminIpAllowlist })} validate={isIpRule} placeholder="Adresse IP ou plage CIDR" />
127
+ </Field>
128
+ <div className="flex flex-wrap items-center gap-2 text-[13px] text-slate-500">
129
+ Votre adresse actuelle : <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-700">{meta.clientIp}</code>
130
+ {!allowlist.includes(meta.clientIp) ? (
131
+ <Button size="sm" variant="ghost" icon={Plus} onClick={() => set('security', { adminIpAllowlist: [...allowlist, meta.clientIp] })}>
132
+ Ajouter mon adresse
133
+ </Button>
134
+ ) : null}
135
+ </div>
136
+ {selfBlocked ? (
137
+ <Alert tone="danger" icon={AlertTriangle} title="Vous perdriez l’accès">
138
+ Votre adresse actuelle n’est pas couverte par la liste : l’enregistrement sera refusé.
139
+ </Alert>
140
+ ) : null}
141
+ {meta.ipBypass ? (
142
+ <Alert tone="info">
143
+ Restriction suspendue par la variable <code className="font-mono text-xs">SNAPIFY_ADMIN_IP_BYPASS=true</code> (procédure de secours).
144
+ </Alert>
145
+ ) : (
146
+ <p className="text-xs leading-relaxed text-slate-500">
147
+ En cas de blocage, définissez <code className="font-mono">SNAPIFY_ADMIN_IP_BYPASS=true</code> dans le fichier .env du serveur puis redémarrez-le.
148
+ </p>
149
+ )}
150
+ </Section>
151
+
152
+ <Section title="En-têtes et chiffrement">
153
+ <Switch
154
+ checked={sec.hsts}
155
+ onChange={(hsts) => set('security', { hsts })}
156
+ label="HSTS (Strict-Transport-Security)"
157
+ description="Impose HTTPS aux navigateurs pendant un an. N’activez qu’une fois le certificat HTTPS installé de manière durable."
158
+ />
159
+ {meta.dedicatedSecretKey === false ? (
160
+ <Alert tone="warning" icon={ShieldCheck} title="Clé de chiffrement dédiée recommandée">
161
+ Les secrets (mot de passe SMTP…) sont chiffrés avec la clé des sessions. Définissez <code className="font-mono text-xs">SNAPIFY_SECRET_KEY</code> dans le fichier .env pour les séparer.
162
+ </Alert>
163
+ ) : meta.dedicatedSecretKey ? (
164
+ <Alert tone="success" icon={ShieldCheck}>
165
+ Les secrets sont chiffrés avec une clé dédiée (SNAPIFY_SECRET_KEY).
166
+ </Alert>
167
+ ) : null}
168
+ </Section>
169
+ </div>
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Snapify
3
+ * © 2026 NOVALABS WEB DESIGN — Tunisie
4
+ * Contact : contact@novalabs.tn · +216 24 734 647
5
+ * Tous droits réservés. Toute reproduction, modification ou diffusion sans autorisation écrite est interdite.
6
+ */
7
+ import React from 'react';
8
+ import type { SystemMeta, SystemView } from '../lib/api';
9
+ import { Field, Input } from '../ui/primitives';
10
+
11
+ export type SystemForm = Omit<SystemView, 'smtp'> & { smtp: SystemView['smtp'] & { password: string; clearPassword: boolean } };
12
+
13
+ export interface TabProps {
14
+ v: SystemForm;
15
+ set: <K extends keyof SystemForm>(key: K, patch: Partial<SystemForm[K]>) => void;
16
+ meta: SystemMeta;
17
+ }
18
+
19
+ /** Bornes identiques à la validation serveur : [min, max, libellé, minimum si non nul]. */
20
+ const BOUNDS: Record<string, [number, number, string, number?]> = {
21
+ 'smtp.port': [1, 65535, 'Port SMTP'],
22
+ 'smtp.timeoutSec': [3, 120, 'Délai SMTP'],
23
+ 'security.passwordMinLength': [8, 64, 'Longueur minimale du mot de passe'],
24
+ 'security.passwordExpiryDays': [0, 730, 'Expiration du mot de passe'],
25
+ 'security.sessionHours': [1, 168, 'Durée de session'],
26
+ 'security.rememberDays': [1, 90, 'Durée « Rester connecté »'],
27
+ 'security.idleMinutes': [0, 1440, 'Déconnexion après inactivité'],
28
+ 'security.maxLoginAttempts': [3, 50, 'Tentatives de connexion'],
29
+ 'security.lockoutMinutes': [1, 1440, 'Durée de blocage'],
30
+ 'limits.publicApiPerMinute': [0, 100_000, 'API publique', 30],
31
+ 'limits.adminApiPerMinute': [0, 100_000, 'API administration', 60],
32
+ 'limits.formSubmissions.max': [1, 10_000, 'Formulaires (nombre)'],
33
+ 'limits.formSubmissions.windowMinutes': [1, 1440, 'Formulaires (durée)'],
34
+ 'limits.newsletter.max': [1, 10_000, 'Newsletter (nombre)'],
35
+ 'limits.newsletter.windowMinutes': [1, 1440, 'Newsletter (durée)'],
36
+ 'limits.passwordReset.max': [1, 10_000, 'Mot de passe oublié (nombre)'],
37
+ 'limits.passwordReset.windowMinutes': [1, 1440, 'Mot de passe oublié (durée)'],
38
+ 'uploads.maxSizeMb': [1, 100, 'Taille maximale des fichiers'],
39
+ 'privacy.analyticsRetentionDays': [30, 3650, 'Conservation des statistiques'],
40
+ 'privacy.submissionsRetentionDays': [0, 3650, 'Conservation des messages'],
41
+ 'privacy.auditRetentionDays': [30, 3650, 'Conservation du journal d’activité'],
42
+ 'privacy.mailLogRetentionDays': [7, 3650, 'Conservation du journal des e-mails'],
43
+ 'performance.publicCacheSeconds': [0, 3600, 'Cache public'],
44
+ };
45
+
46
+ const at = (obj: unknown, path: string): unknown => path.split('.').reduce<unknown>((o, k) => (o as Record<string, unknown> | undefined)?.[k], obj);
47
+
48
+ export function boundError(path: string, value: number): string | undefined {
49
+ const b = BOUNDS[path];
50
+ if (!b) return undefined;
51
+ const [min, max, , nonZeroMin] = b;
52
+ if (!Number.isInteger(value) || value < min || value > max) return `Valeur entière entre ${min} et ${max} attendue`;
53
+ if (nonZeroMin && value > 0 && value < nonZeroMin) return `${nonZeroMin} minimum (ou 0 pour illimité)`;
54
+ return undefined;
55
+ }
56
+
57
+ /** Première erreur de saisie du formulaire, au format « Libellé : message ». */
58
+ export function firstFormError(v: SystemForm): string | null {
59
+ for (const [path, [, , label]] of Object.entries(BOUNDS)) {
60
+ const err = boundError(path, Number(at(v, path)));
61
+ if (err) return `${label} : ${err.charAt(0).toLowerCase()}${err.slice(1)}`;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ export function NumberField({
67
+ path,
68
+ label,
69
+ hint,
70
+ value,
71
+ onChange,
72
+ suffix,
73
+ className,
74
+ }: {
75
+ path: string;
76
+ label: React.ReactNode;
77
+ hint?: React.ReactNode;
78
+ value: number;
79
+ onChange: (v: number) => void;
80
+ suffix?: string;
81
+ className?: string;
82
+ }) {
83
+ const err = boundError(path, value);
84
+ const [min, max] = BOUNDS[path] ?? [undefined, undefined];
85
+ return (
86
+ <Field label={label} hint={hint} error={err} className={className}>
87
+ <div className="flex items-center gap-2">
88
+ <div className="w-28 shrink-0">
89
+ <Input
90
+ type="number"
91
+ inputMode="numeric"
92
+ min={min}
93
+ max={max}
94
+ step={1}
95
+ value={Number.isFinite(value) ? value : ''}
96
+ invalid={!!err}
97
+ onChange={(e) => onChange(e.target.value === '' ? Number.NaN : Number(e.target.value))}
98
+ className="tabular-nums"
99
+ />
100
+ </div>
101
+ {suffix ? <span className="text-sm text-slate-500">{suffix}</span> : null}
102
+ </div>
103
+ </Field>
104
+ );
105
+ }
106
+
107
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
108
+
109
+ export function isIpRule(rule: string): boolean {
110
+ const [ip, bits, extra] = rule.split('/');
111
+ if (extra !== undefined) return false;
112
+ const m = IPV4.exec(ip);
113
+ if (m) return m.slice(1).every((x) => Number(x) <= 255) && (bits === undefined || (/^\d{1,2}$/.test(bits) && Number(bits) <= 32));
114
+ return /^[0-9a-f:]+$/i.test(ip) && ip.includes(':') && (bits === undefined || (/^\d{1,3}$/.test(bits) && Number(bits) <= 128));
115
+ }
116
+
117
+ const v4 = (ip: string) => {
118
+ const m = IPV4.exec(ip);
119
+ return m ? m.slice(1).reduce((n, x) => n * 256 + Number(x), 0) : null;
120
+ };
121
+
122
+ /** Indique si l'adresse est couverte par la liste (adresse exacte ou plage IPv4). */
123
+ export function ipInList(ip: string, rules: string[]): boolean {
124
+ return rules.some((r) => {
125
+ if (r === ip) return true;
126
+ const [base, bits] = r.split('/');
127
+ const a = v4(ip);
128
+ const b = v4(base);
129
+ if (a === null || b === null || bits === undefined) return false;
130
+ const size = 2 ** (32 - Number(bits));
131
+ return Math.floor(a / size) === Math.floor(b / size);
132
+ });
133
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Snapify
3
+ * © 2026 NOVALABS WEB DESIGN — Tunisie
4
+ * Contact : contact@novalabs.tn · +216 24 734 647
5
+ * Tous droits réservés. Toute reproduction, modification ou diffusion sans autorisation écrite est interdite.
6
+ */
7
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
8
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
9
+ import { cx } from './primitives';
10
+
11
+ export const PAGE_SIZES = [10, 25, 50, 100] as const;
12
+
13
+ function storedSize(id: string, fallback: number): number {
14
+ try {
15
+ const v = Number(localStorage.getItem(`snapify.pageSize.${id}`));
16
+ return (PAGE_SIZES as readonly number[]).includes(v) ? v : fallback;
17
+ } catch {
18
+ return fallback;
19
+ }
20
+ }
21
+
22
+ /** Page courante + taille de page mémorisée par liste ; la page revient à 1 quand `resetKey` change (filtres, recherche). */
23
+ export function usePaging(id: string, { defaultSize = 25, resetKey }: { defaultSize?: number; resetKey?: unknown } = {}) {
24
+ const [pageSize, setSize] = useState(() => storedSize(id, defaultSize));
25
+ const [page, setPage] = useState(1);
26
+ const key = JSON.stringify(resetKey ?? null);
27
+ const first = useRef(true);
28
+ useEffect(() => {
29
+ if (first.current) {
30
+ first.current = false;
31
+ return;
32
+ }
33
+ setPage(1);
34
+ }, [key]);
35
+ const setPageSize = (n: number) => {
36
+ try {
37
+ localStorage.setItem(`snapify.pageSize.${id}`, String(n));
38
+ } catch {
39
+ /* stockage indisponible : la taille reste valable pour la session */
40
+ }
41
+ setSize(n);
42
+ setPage(1);
43
+ };
44
+ return { page, setPage, pageSize, setPageSize };
45
+ }
46
+
47
+ /** Pagination côté navigateur d'une liste déjà chargée. */
48
+ export function usePagination<T>(items: readonly T[], id: string, opts: { defaultSize?: number; resetKey?: unknown } = {}) {
49
+ const { page, setPage, pageSize, setPageSize } = usePaging(id, opts);
50
+ const total = items.length;
51
+ const pages = Math.max(1, Math.ceil(total / pageSize));
52
+ const current = Math.min(page, pages);
53
+ const pageItems = useMemo(() => items.slice((current - 1) * pageSize, current * pageSize), [items, current, pageSize]);
54
+ return {
55
+ pageItems,
56
+ /** Position de la première ligne affichée dans la liste complète. */
57
+ offset: (current - 1) * pageSize,
58
+ setPage,
59
+ props: { page: current, pageSize, total, onPage: setPage, onPageSize: setPageSize },
60
+ };
61
+ }
62
+
63
+ function pageList(page: number, pages: number): (number | 'gap')[] {
64
+ if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
65
+ const set = new Set([1, pages, page - 1, page, page + 1].filter((p) => p >= 1 && p <= pages));
66
+ if (page <= 3) [2, 3, 4].forEach((p) => set.add(p));
67
+ if (page >= pages - 2) [pages - 3, pages - 2, pages - 1].forEach((p) => set.add(p));
68
+ const sorted = [...set].sort((a, b) => a - b);
69
+ const out: (number | 'gap')[] = [];
70
+ sorted.forEach((p, i) => {
71
+ if (i && p - sorted[i - 1] > 1) out.push('gap');
72
+ out.push(p);
73
+ });
74
+ return out;
75
+ }
76
+
77
+ export interface PaginationProps {
78
+ page: number;
79
+ pageSize: number;
80
+ total: number;
81
+ onPage: (page: number) => void;
82
+ onPageSize?: (size: number) => void;
83
+ /** Libellé des éléments comptés, au pluriel en « s » (ex. « pages », « abonnés ») ; le singulier en est déduit. */
84
+ noun?: string;
85
+ /** Sans marge latérale, pour une liste déjà placée dans un bloc avec marges. */
86
+ flush?: boolean;
87
+ className?: string;
88
+ }
89
+
90
+ export function Pagination({ page, pageSize, total, onPage, onPageSize, noun = 'éléments', flush, className }: PaginationProps) {
91
+ const ref = useRef<HTMLDivElement>(null);
92
+ if (total <= 0) return null;
93
+ const pages = Math.max(1, Math.ceil(total / pageSize));
94
+ const current = Math.min(page, pages);
95
+ const from = (current - 1) * pageSize + 1;
96
+ const to = Math.min(total, current * pageSize);
97
+
98
+ const go = (p: number) => {
99
+ onPage(Math.min(Math.max(1, p), pages));
100
+ const list = ref.current?.parentElement;
101
+ if (list && list.getBoundingClientRect().top < 0) list.scrollIntoView({ block: 'start', behavior: 'smooth' });
102
+ };
103
+
104
+ const btn = 'flex h-8 min-w-8 items-center justify-center rounded-lg px-2 text-[13px] font-medium tabular-nums transition disabled:pointer-events-none disabled:opacity-40';
105
+
106
+ return (
107
+ <div ref={ref} className={cx('flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-slate-100 text-[13px] text-slate-500', flush ? 'pt-3' : 'px-4 py-2.5', className)}>
108
+ <div className="flex items-center gap-4">
109
+ <span className="tabular-nums">
110
+ {pages > 1 ? (
111
+ <>
112
+ <span className="font-medium text-slate-700">
113
+ {from.toLocaleString('fr-FR')}–{to.toLocaleString('fr-FR')}
114
+ </span>{' '}
115
+ sur {total.toLocaleString('fr-FR')} {noun}
116
+ </>
117
+ ) : (
118
+ <>
119
+ <span className="font-medium text-slate-700">{total.toLocaleString('fr-FR')}</span> {total === 1 ? noun.replace(/s$/, '') : noun}
120
+ </>
121
+ )}
122
+ </span>
123
+ {onPageSize && total > PAGE_SIZES[0] ? (
124
+ <label className="flex items-center gap-2">
125
+ <span className="hidden sm:inline">Par page</span>
126
+ <select
127
+ value={pageSize}
128
+ onChange={(e) => onPageSize(Number(e.target.value))}
129
+ className="h-8 rounded-lg border border-slate-200 bg-white pl-2 pr-7 text-[13px] text-slate-700 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
130
+ aria-label="Éléments par page"
131
+ >
132
+ {PAGE_SIZES.map((n) => (
133
+ <option key={n} value={n}>
134
+ {n}
135
+ </option>
136
+ ))}
137
+ </select>
138
+ </label>
139
+ ) : null}
140
+ </div>
141
+ {pages > 1 ? (
142
+ <nav className="flex items-center gap-1" aria-label="Pagination">
143
+ <button type="button" className={cx(btn, 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')} disabled={current <= 1} onClick={() => go(current - 1)} aria-label="Page précédente">
144
+ <ChevronLeft className="h-4 w-4" />
145
+ </button>
146
+ {pageList(current, pages).map((p, i) =>
147
+ p === 'gap' ? (
148
+ <span key={`gap-${i}`} className="px-1 text-slate-400">
149
+ …
150
+ </span>
151
+ ) : (
152
+ <button
153
+ key={p}
154
+ type="button"
155
+ onClick={() => go(p)}
156
+ aria-current={p === current ? 'page' : undefined}
157
+ className={cx(btn, p === current ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900')}
158
+ >
159
+ {p}
160
+ </button>
161
+ )
162
+ )}
163
+ <button type="button" className={cx(btn, 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')} disabled={current >= pages} onClick={() => go(current + 1)} aria-label="Page suivante">
164
+ <ChevronRight className="h-4 w-4" />
165
+ </button>
166
+ </nav>
167
+ ) : null}
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Snapify
3
+ * © 2026 NOVALABS WEB DESIGN — Tunisie
4
+ * Contact : contact@novalabs.tn · +216 24 734 647
5
+ * Tous droits réservés. Toute reproduction, modification ou diffusion sans autorisation écrite est interdite.
6
+ */
7
+ import React from 'react';
8
+ import type { Blocker } from 'react-router-dom';
9
+ import { Button, Kbd } from './primitives';
10
+ import { Modal } from './overlays';
11
+
12
+ /** Barre flottante affichée dès qu'il y a des modifications non enregistrées. */
13
+ export function SaveBar({
14
+ dirty,
15
+ saving,
16
+ onSave,
17
+ onReset,
18
+ blocker,
19
+ }: {
20
+ dirty: boolean;
21
+ saving: boolean;
22
+ onSave: () => Promise<boolean> | void;
23
+ onReset: () => void;
24
+ blocker?: Blocker;
25
+ }) {
26
+ return (
27
+ <>
28
+ <div
29
+ className={`pointer-events-none fixed inset-x-0 bottom-5 z-40 flex justify-center px-4 transition-all duration-200 lg:pl-64 ${
30
+ dirty ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
31
+ }`}
32
+ >
33
+ <div className="pointer-events-auto flex items-center gap-4 rounded-2xl bg-navy-900 py-2 pl-5 pr-2 text-white shadow-2xl ring-1 ring-white/10">
34
+ <span className="flex items-center gap-2 text-sm">
35
+ <span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
36
+ Modifications non enregistrées
37
+ <span className="hidden text-slate-400 sm:inline">
38
+ · <Kbd>Ctrl</Kbd> + <Kbd>S</Kbd>
39
+ </span>
40
+ </span>
41
+ <Button variant="ghost" size="sm" className="text-slate-300 hover:bg-white/10 hover:text-white" onClick={onReset} disabled={saving}>
42
+ Annuler
43
+ </Button>
44
+ <Button variant="secondary" size="sm" loading={saving} onClick={() => void onSave()}>
45
+ Enregistrer
46
+ </Button>
47
+ </div>
48
+ </div>
49
+
50
+ {blocker ? (
51
+ <Modal
52
+ open={blocker.state === 'blocked'}
53
+ onClose={() => blocker.reset?.()}
54
+ size="sm"
55
+ title="Quitter sans enregistrer ?"
56
+ footer={
57
+ <>
58
+ <Button onClick={() => blocker.proceed?.()}>Quitter sans enregistrer</Button>
59
+ <Button
60
+ variant="primary"
61
+ loading={saving}
62
+ onClick={async () => {
63
+ const ok = await onSave();
64
+ if (ok !== false) blocker.proceed?.();
65
+ }}
66
+ >
67
+ Enregistrer et quitter
68
+ </Button>
69
+ </>
70
+ }
71
+ >
72
+ <p className="text-sm text-slate-600">Vos modifications sur cette page seront perdues si vous la quittez maintenant.</p>
73
+ </Modal>
74
+ ) : null}
75
+ </>
76
+ );
77
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Snapify
3
+ * © 2026 NOVALABS WEB DESIGN — Tunisie
4
+ * Contact : contact@novalabs.tn · +216 24 734 647
5
+ * Tous droits réservés. Toute reproduction, modification ou diffusion sans autorisation écrite est interdite.
6
+ */
7
+ import React, { useState } from 'react';
8
+ import { X } from 'lucide-react';
9
+
10
+ export function TagInput({
11
+ value,
12
+ onChange,
13
+ placeholder = 'Ajouter puis Entrée',
14
+ validate,
15
+ }: {
16
+ value: string[];
17
+ onChange: (v: string[]) => void;
18
+ placeholder?: string;
19
+ validate?: (v: string) => boolean;
20
+ }) {
21
+ const [draft, setDraft] = useState('');
22
+ const add = () => {
23
+ const t = draft.trim().replace(/,$/, '');
24
+ if (t && !value.includes(t) && (!validate || validate(t))) onChange([...value, t]);
25
+ else if (t && validate && !validate(t)) return;
26
+ setDraft('');
27
+ };
28
+ const invalid = !!draft.trim() && !!validate && !validate(draft.trim());
29
+ return (
30
+ <div className={`flex min-h-9 flex-wrap items-center gap-1.5 rounded-lg border ${invalid ? 'border-rose-400' : 'border-slate-200'} bg-white px-2 py-1.5 shadow-xs focus-within:border-brand-500 focus-within:ring-3 focus-within:ring-brand-500/15`}>
31
+ {value.map((t) => (
32
+ <span key={t} className="inline-flex items-center gap-1 rounded-md bg-slate-100 py-0.5 pl-2 pr-1 text-xs font-medium text-slate-700">
33
+ {t}
34
+ <button type="button" onClick={() => onChange(value.filter((x) => x !== t))} className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700" aria-label={`Retirer ${t}`}>
35
+ <X className="h-3 w-3" />
36
+ </button>
37
+ </span>
38
+ ))}
39
+ <input
40
+ value={draft}
41
+ onChange={(e) => setDraft(e.target.value)}
42
+ onKeyDown={(e) => {
43
+ if (e.key === 'Enter' || e.key === ',') {
44
+ e.preventDefault();
45
+ add();
46
+ } else if (e.key === 'Backspace' && !draft && value.length) onChange(value.slice(0, -1));
47
+ }}
48
+ onBlur={add}
49
+ placeholder={value.length ? '' : placeholder}
50
+ className="min-w-[120px] flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-slate-400"
51
+ />
52
+ </div>
53
+ );
54
+ }