@iann29/rastro 0.9.0 → 0.10.0

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 (257) hide show
  1. package/README.md +9 -1
  2. package/agent/integration.md +23 -1
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +114 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +54 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +637 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +385 -9
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +14 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +277 -1
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +4 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +5 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +10 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +4 -0
  25. package/dist/component/convex.config.js +9 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/leads.d.ts +69 -3
  31. package/dist/component/leads.d.ts.map +1 -1
  32. package/dist/component/leads.js +419 -16
  33. package/dist/component/leads.js.map +1 -1
  34. package/dist/component/meta.d.ts +352 -0
  35. package/dist/component/meta.d.ts.map +1 -0
  36. package/dist/component/meta.js +1239 -0
  37. package/dist/component/meta.js.map +1 -0
  38. package/dist/component/metaCapi.d.ts +162 -0
  39. package/dist/component/metaCapi.d.ts.map +1 -0
  40. package/dist/component/metaCapi.js +754 -0
  41. package/dist/component/metaCapi.js.map +1 -0
  42. package/dist/component/metaFake.d.ts +5 -0
  43. package/dist/component/metaFake.d.ts.map +1 -0
  44. package/dist/component/metaFake.js +225 -0
  45. package/dist/component/metaFake.js.map +1 -0
  46. package/dist/component/metaGraph.d.ts +165 -0
  47. package/dist/component/metaGraph.d.ts.map +1 -0
  48. package/dist/component/metaGraph.js +507 -0
  49. package/dist/component/metaGraph.js.map +1 -0
  50. package/dist/component/metaSync.d.ts +186 -0
  51. package/dist/component/metaSync.d.ts.map +1 -0
  52. package/dist/component/metaSync.js +961 -0
  53. package/dist/component/metaSync.js.map +1 -0
  54. package/dist/component/pipeline.d.ts +98 -0
  55. package/dist/component/pipeline.d.ts.map +1 -0
  56. package/dist/component/pipeline.js +324 -0
  57. package/dist/component/pipeline.js.map +1 -0
  58. package/dist/component/schema.d.ts +404 -2
  59. package/dist/component/schema.d.ts.map +1 -1
  60. package/dist/component/schema.js +168 -2
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/secrets.d.ts +15 -0
  63. package/dist/component/secrets.d.ts.map +1 -0
  64. package/dist/component/secrets.js +67 -0
  65. package/dist/component/secrets.js.map +1 -0
  66. package/dist/component/validators.d.ts +1052 -15
  67. package/dist/component/validators.d.ts.map +1 -1
  68. package/dist/component/validators.js +307 -3
  69. package/dist/component/validators.js.map +1 -1
  70. package/dist/tracker/generated.d.ts +1 -1
  71. package/dist/tracker/generated.d.ts.map +1 -1
  72. package/dist/tracker/generated.js +1 -1
  73. package/dist/tracker/generated.js.map +1 -1
  74. package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
  75. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
  76. package/dist/ui/campaigns/CampaignsView.js +47 -0
  77. package/dist/ui/campaigns/CampaignsView.js.map +1 -0
  78. package/dist/ui/campaigns/Conversions.d.ts +16 -0
  79. package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
  80. package/dist/ui/campaigns/Conversions.js +40 -0
  81. package/dist/ui/campaigns/Conversions.js.map +1 -0
  82. package/dist/ui/campaigns/Drawer.d.ts +24 -0
  83. package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
  84. package/dist/ui/campaigns/Drawer.js +120 -0
  85. package/dist/ui/campaigns/Drawer.js.map +1 -0
  86. package/dist/ui/campaigns/Icons.d.ts +35 -0
  87. package/dist/ui/campaigns/Icons.d.ts.map +1 -0
  88. package/dist/ui/campaigns/Icons.js +41 -0
  89. package/dist/ui/campaigns/Icons.js.map +1 -0
  90. package/dist/ui/campaigns/Summary.d.ts +21 -0
  91. package/dist/ui/campaigns/Summary.d.ts.map +1 -0
  92. package/dist/ui/campaigns/Summary.js +56 -0
  93. package/dist/ui/campaigns/Summary.js.map +1 -0
  94. package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
  95. package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
  96. package/dist/ui/campaigns/Thumbnail.js +23 -0
  97. package/dist/ui/campaigns/Thumbnail.js.map +1 -0
  98. package/dist/ui/campaigns/Tree.d.ts +12 -0
  99. package/dist/ui/campaigns/Tree.d.ts.map +1 -0
  100. package/dist/ui/campaigns/Tree.js +105 -0
  101. package/dist/ui/campaigns/Tree.js.map +1 -0
  102. package/dist/ui/campaigns/glyphs.d.ts +20 -0
  103. package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
  104. package/dist/ui/campaigns/glyphs.js +19 -0
  105. package/dist/ui/campaigns/glyphs.js.map +1 -0
  106. package/dist/ui/campaigns/index.d.ts +4 -0
  107. package/dist/ui/campaigns/index.d.ts.map +1 -0
  108. package/dist/ui/campaigns/index.js +3 -0
  109. package/dist/ui/campaigns/index.js.map +1 -0
  110. package/dist/ui/campaigns/labels.d.ts +53 -0
  111. package/dist/ui/campaigns/labels.d.ts.map +1 -0
  112. package/dist/ui/campaigns/labels.js +169 -0
  113. package/dist/ui/campaigns/labels.js.map +1 -0
  114. package/dist/ui/campaigns/model.d.ts +99 -0
  115. package/dist/ui/campaigns/model.d.ts.map +1 -0
  116. package/dist/ui/campaigns/model.js +230 -0
  117. package/dist/ui/campaigns/model.js.map +1 -0
  118. package/dist/ui/campaigns/modes.d.ts +18 -0
  119. package/dist/ui/campaigns/modes.d.ts.map +1 -0
  120. package/dist/ui/campaigns/modes.js +7 -0
  121. package/dist/ui/campaigns/modes.js.map +1 -0
  122. package/dist/ui/index.d.ts +21 -0
  123. package/dist/ui/index.d.ts.map +1 -0
  124. package/dist/ui/index.js +19 -0
  125. package/dist/ui/index.js.map +1 -0
  126. package/dist/ui/integrations/AccountPicker.d.ts +26 -0
  127. package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
  128. package/dist/ui/integrations/AccountPicker.js +97 -0
  129. package/dist/ui/integrations/AccountPicker.js.map +1 -0
  130. package/dist/ui/integrations/ClientCards.d.ts +17 -0
  131. package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
  132. package/dist/ui/integrations/ClientCards.js +38 -0
  133. package/dist/ui/integrations/ClientCards.js.map +1 -0
  134. package/dist/ui/integrations/ClientsTable.d.ts +36 -0
  135. package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
  136. package/dist/ui/integrations/ClientsTable.js +127 -0
  137. package/dist/ui/integrations/ClientsTable.js.map +1 -0
  138. package/dist/ui/integrations/Connect.d.ts +29 -0
  139. package/dist/ui/integrations/Connect.d.ts.map +1 -0
  140. package/dist/ui/integrations/Connect.js +49 -0
  141. package/dist/ui/integrations/Connect.js.map +1 -0
  142. package/dist/ui/integrations/Connection.d.ts +37 -0
  143. package/dist/ui/integrations/Connection.d.ts.map +1 -0
  144. package/dist/ui/integrations/Connection.js +94 -0
  145. package/dist/ui/integrations/Connection.js.map +1 -0
  146. package/dist/ui/integrations/Dialogs.d.ts +18 -0
  147. package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
  148. package/dist/ui/integrations/Dialogs.js +25 -0
  149. package/dist/ui/integrations/Dialogs.js.map +1 -0
  150. package/dist/ui/integrations/EventsFeed.d.ts +15 -0
  151. package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
  152. package/dist/ui/integrations/EventsFeed.js +57 -0
  153. package/dist/ui/integrations/EventsFeed.js.map +1 -0
  154. package/dist/ui/integrations/EventsSheet.d.ts +28 -0
  155. package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
  156. package/dist/ui/integrations/EventsSheet.js +121 -0
  157. package/dist/ui/integrations/EventsSheet.js.map +1 -0
  158. package/dist/ui/integrations/Icons.d.ts +36 -0
  159. package/dist/ui/integrations/Icons.d.ts.map +1 -0
  160. package/dist/ui/integrations/Icons.js +38 -0
  161. package/dist/ui/integrations/Icons.js.map +1 -0
  162. package/dist/ui/integrations/IntegrationsView.d.ts +14 -0
  163. package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
  164. package/dist/ui/integrations/IntegrationsView.js +113 -0
  165. package/dist/ui/integrations/IntegrationsView.js.map +1 -0
  166. package/dist/ui/integrations/Overlay.d.ts +18 -0
  167. package/dist/ui/integrations/Overlay.d.ts.map +1 -0
  168. package/dist/ui/integrations/Overlay.js +36 -0
  169. package/dist/ui/integrations/Overlay.js.map +1 -0
  170. package/dist/ui/integrations/RowMenu.d.ts +15 -0
  171. package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
  172. package/dist/ui/integrations/RowMenu.js +47 -0
  173. package/dist/ui/integrations/RowMenu.js.map +1 -0
  174. package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
  175. package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
  176. package/dist/ui/integrations/UnlinkDialog.js +29 -0
  177. package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
  178. package/dist/ui/integrations/format.d.ts +28 -0
  179. package/dist/ui/integrations/format.d.ts.map +1 -0
  180. package/dist/ui/integrations/format.js +101 -0
  181. package/dist/ui/integrations/format.js.map +1 -0
  182. package/dist/ui/integrations/index.d.ts +5 -0
  183. package/dist/ui/integrations/index.d.ts.map +1 -0
  184. package/dist/ui/integrations/index.js +3 -0
  185. package/dist/ui/integrations/index.js.map +1 -0
  186. package/dist/ui/integrations/model.d.ts +84 -0
  187. package/dist/ui/integrations/model.d.ts.map +1 -0
  188. package/dist/ui/integrations/model.js +131 -0
  189. package/dist/ui/integrations/model.js.map +1 -0
  190. package/dist/ui/integrations/overlay.d.ts +10 -0
  191. package/dist/ui/integrations/overlay.d.ts.map +1 -0
  192. package/dist/ui/integrations/overlay.js +77 -0
  193. package/dist/ui/integrations/overlay.js.map +1 -0
  194. package/dist/ui/integrations/parts.d.ts +36 -0
  195. package/dist/ui/integrations/parts.d.ts.map +1 -0
  196. package/dist/ui/integrations/parts.js +85 -0
  197. package/dist/ui/integrations/parts.js.map +1 -0
  198. package/dist/ui/integrations/types.d.ts +96 -0
  199. package/dist/ui/integrations/types.d.ts.map +1 -0
  200. package/dist/ui/integrations/types.js +2 -0
  201. package/dist/ui/integrations/types.js.map +1 -0
  202. package/dist/ui/metrics.d.ts +106 -0
  203. package/dist/ui/metrics.d.ts.map +1 -0
  204. package/dist/ui/metrics.js +166 -0
  205. package/dist/ui/metrics.js.map +1 -0
  206. package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
  207. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
  208. package/dist/ui/portfolio/PortfolioView.js +216 -0
  209. package/dist/ui/portfolio/PortfolioView.js.map +1 -0
  210. package/dist/ui/portfolio/SpendChart.d.ts +10 -0
  211. package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
  212. package/dist/ui/portfolio/SpendChart.js +196 -0
  213. package/dist/ui/portfolio/SpendChart.js.map +1 -0
  214. package/dist/ui/portfolio/alerts.d.ts +74 -0
  215. package/dist/ui/portfolio/alerts.d.ts.map +1 -0
  216. package/dist/ui/portfolio/alerts.js +291 -0
  217. package/dist/ui/portfolio/alerts.js.map +1 -0
  218. package/dist/ui/portfolio/figures.d.ts +79 -0
  219. package/dist/ui/portfolio/figures.d.ts.map +1 -0
  220. package/dist/ui/portfolio/figures.js +103 -0
  221. package/dist/ui/portfolio/figures.js.map +1 -0
  222. package/dist/ui/portfolio/format.d.ts +8 -0
  223. package/dist/ui/portfolio/format.d.ts.map +1 -0
  224. package/dist/ui/portfolio/format.js +28 -0
  225. package/dist/ui/portfolio/format.js.map +1 -0
  226. package/dist/ui/sample.d.ts +474 -0
  227. package/dist/ui/sample.d.ts.map +1 -0
  228. package/dist/ui/sample.js +553 -0
  229. package/dist/ui/sample.js.map +1 -0
  230. package/dist/ui/seed.d.ts +188 -0
  231. package/dist/ui/seed.d.ts.map +1 -0
  232. package/dist/ui/seed.js +563 -0
  233. package/dist/ui/seed.js.map +1 -0
  234. package/dist/ui/ui.css +3356 -0
  235. package/docs/federation.md +46 -1
  236. package/docs/meta-ads.md +746 -0
  237. package/docs/upgrading.md +95 -0
  238. package/llms.txt +3 -0
  239. package/package.json +15 -3
  240. package/src/component/_generated/api.ts +14 -0
  241. package/src/component/_generated/component.ts +323 -1
  242. package/src/component/_generated/server.ts +4 -0
  243. package/src/component/constants.ts +10 -0
  244. package/src/component/convex.config.ts +9 -0
  245. package/src/component/errors.ts +5 -1
  246. package/src/component/leads.ts +517 -17
  247. package/src/component/meta.ts +1461 -0
  248. package/src/component/metaCapi.ts +921 -0
  249. package/src/component/metaFake.ts +277 -0
  250. package/src/component/metaGraph.ts +722 -0
  251. package/src/component/metaSync.ts +1164 -0
  252. package/src/component/pipeline.ts +392 -0
  253. package/src/component/schema.ts +197 -1
  254. package/src/component/secrets.ts +98 -0
  255. package/src/component/validators.ts +376 -3
  256. package/src/tracker/generated.ts +1 -1
  257. package/src/ui/seed.ts +755 -0
package/src/ui/seed.ts ADDED
@@ -0,0 +1,755 @@
1
+ /**
2
+ * The sample agency's raw seed: its four clients, their ad accounts, catalog
3
+ * and every ad's funnel and spend day by day. `@iann29/rastro/ui/sample` builds
4
+ * its reports from here, the example host's `seedDemo` writes it, and the
5
+ * component's Meta fake (`RASTRO_META_FAKE`) answers from it. It ships as
6
+ * source with the component, so it imports nothing but the component's own
7
+ * generated API: no client, no screen types.
8
+ */
9
+ import type { FunctionReturnType } from "convex/server";
10
+ import type { ComponentApi } from "../component/_generated/component.js";
11
+
12
+ type LeadsDaily = FunctionReturnType<ComponentApi["leads"]["daily"]>;
13
+ type LeadSummary = FunctionReturnType<ComponentApi["leads"]["list"]>[number];
14
+
15
+ export const DAY_MS = 86_400_000;
16
+ export const MINUTE_MS = 60_000;
17
+
18
+ /** 29/08/2026 → 27/09/2026: the mockups' "últimos 30 dias", UTC days. */
19
+ export const SAMPLE_RANGE = Object.freeze({
20
+ from: Date.UTC(2026, 7, 29),
21
+ to: Date.UTC(2026, 8, 28) - 1,
22
+ });
23
+ /** The 30 days before, for the change against the previous period. */
24
+ export const SAMPLE_PREVIOUS_RANGE = Object.freeze({
25
+ from: SAMPLE_RANGE.from - 30 * DAY_MS,
26
+ to: SAMPLE_RANGE.from - 1,
27
+ });
28
+ /** 28/09/2026 11:15 in Brasília: the mockups' "há 2 h" is from here. */
29
+ export const SAMPLE_NOW = Date.UTC(2026, 8, 28, 14, 15);
30
+
31
+ /** Brasília wall time (UTC−3) as a UTC timestamp. */
32
+ export const brt = (month: number, day: number, hour: number, minute: number) =>
33
+ Date.UTC(2026, month - 1, day, hour + 3, minute);
34
+
35
+ /** [spend, leads, qualified, sales, revenue], in whole reais. */
36
+ export type Figures = readonly [
37
+ spend: number,
38
+ leads: number,
39
+ qualified: number,
40
+ won: number,
41
+ revenue: number,
42
+ ];
43
+ /** [leads, qualified, sales, revenue] of an origin no ad paid for. */
44
+ type OrganicFigures = readonly [
45
+ leads: number,
46
+ qualified: number,
47
+ won: number,
48
+ revenue: number,
49
+ ];
50
+
51
+ interface SampleAd {
52
+ adId: string;
53
+ name: string;
54
+ creativeType: "video" | "image" | "carousel";
55
+ sourceApp: "instagram" | "facebook";
56
+ now: Figures;
57
+ before?: Figures;
58
+ }
59
+
60
+ export interface SampleSite {
61
+ siteId: string;
62
+ name: string;
63
+ segment: string;
64
+ adAccount: { adAccountId: string; name: string };
65
+ pixel: { pixelId: string; sent: number; pending: number } | null;
66
+ campaigns: Array<{
67
+ campaignId: string;
68
+ name: string;
69
+ adsets: Array<{ adsetId: string; name: string; ads: SampleAd[] }>;
70
+ }>;
71
+ organic: Record<string, OrganicFigures>;
72
+ organicBefore?: Record<string, OrganicFigures>;
73
+ /** Sales per day over the 30 days (from the mockups' sparklines). */
74
+ dailyWon: readonly number[];
75
+ /** Spend in cents over consecutive stretches of days, oldest first. */
76
+ dailySpend: ReadonlyArray<readonly [days: number, cents: number]>;
77
+ }
78
+
79
+ const steady = (days: number, weight = 1) =>
80
+ Array.from({ length: days }, () => weight);
81
+
82
+ // The spend line of the mockup's "vendas × gasto por dia" chart (Direction C).
83
+ const SPEND_SHAPE = [
84
+ 28, 30, 31, 29, 33, 35, 32, 30, 31, 34, 36, 38, 35, 33, 32, 34, 37, 39, 36,
85
+ 34, 33, 35, 36, 38, 40, 37, 35, 34, 36, 36,
86
+ ];
87
+
88
+ export const LOJA_X_AD = {
89
+ provador: "120210000000000101",
90
+ carrossel: "120210000000000102",
91
+ remarketing: "120210000000000103",
92
+ liquida: "120210000000000201",
93
+ stories: "120210000000000202",
94
+ marca: "120210000000000301",
95
+ } as const;
96
+
97
+ export const SITES: readonly SampleSite[] = [
98
+ {
99
+ siteId: "sample-loja-x",
100
+ name: "Loja X",
101
+ segment: "moda feminina",
102
+ adAccount: { adAccountId: "act_1029384756", name: "Loja X · Anúncios" },
103
+ pixel: { pixelId: "1029384756102938", sent: 159, pending: 0 },
104
+ campaigns: [
105
+ {
106
+ campaignId: "120210000000100001",
107
+ name: "Coleção Verão · Conversas",
108
+ adsets: [
109
+ {
110
+ adsetId: "120210000000010001",
111
+ name: "Mulheres 25–45 · Serra Gaúcha",
112
+ ads: [
113
+ {
114
+ adId: LOJA_X_AD.provador,
115
+ name: "Vídeo provador 15s",
116
+ creativeType: "video",
117
+ sourceApp: "instagram",
118
+ now: [1000, 96, 38, 14, 13300],
119
+ before: [760, 70, 27, 9, 8550],
120
+ },
121
+ {
122
+ adId: LOJA_X_AD.carrossel,
123
+ name: "Carrossel looks",
124
+ creativeType: "carousel",
125
+ sourceApp: "instagram",
126
+ now: [700, 54, 20, 7, 6650],
127
+ before: [690, 50, 18, 6, 5400],
128
+ },
129
+ ],
130
+ },
131
+ {
132
+ adsetId: "120210000000010002",
133
+ name: "Remarketing 30 dias",
134
+ ads: [
135
+ {
136
+ adId: LOJA_X_AD.remarketing,
137
+ name: "Remarketing provador",
138
+ creativeType: "video",
139
+ sourceApp: "instagram",
140
+ now: [900, 64, 22, 8, 7600],
141
+ before: [880, 58, 21, 7, 6650],
142
+ },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ {
148
+ campaignId: "120210000000100002",
149
+ name: "Liquida Inverno · Conversas",
150
+ adsets: [
151
+ {
152
+ adsetId: "120210000000010003",
153
+ name: "Feed · Serra Gaúcha",
154
+ ads: [
155
+ {
156
+ adId: LOJA_X_AD.liquida,
157
+ name: "Liquida 50% OFF",
158
+ creativeType: "image",
159
+ sourceApp: "facebook",
160
+ now: [650, 49, 20, 7, 6370],
161
+ before: [620, 44, 17, 6, 5520],
162
+ },
163
+ ],
164
+ },
165
+ {
166
+ adsetId: "120210000000010004",
167
+ name: "Stories · Serra Gaúcha",
168
+ ads: [
169
+ {
170
+ adId: LOJA_X_AD.stories,
171
+ name: "Stories contagem regressiva",
172
+ creativeType: "video",
173
+ sourceApp: "instagram",
174
+ now: [450, 27, 10, 3, 2730],
175
+ before: [440, 22, 9, 3, 2160],
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ {
182
+ campaignId: "120210000000100003",
183
+ name: "Institucional · Alcance",
184
+ adsets: [
185
+ {
186
+ adsetId: "120210000000010005",
187
+ name: "Público amplo · RS",
188
+ ads: [
189
+ {
190
+ adId: LOJA_X_AD.marca,
191
+ name: "Marca institucional 30s",
192
+ creativeType: "video",
193
+ sourceApp: "facebook",
194
+ now: [500, 22, 8, 2, 2300],
195
+ before: [500, 18, 7, 2, 1450],
196
+ },
197
+ ],
198
+ },
199
+ ],
200
+ },
201
+ ],
202
+ organic: {
203
+ global_search_new_chat: [21, 8, 3, 2850],
204
+ click_to_chat_link: [14, 6, 2, 1780],
205
+ status: [6, 2, 1, 640],
206
+ },
207
+ organicBefore: {
208
+ global_search_new_chat: [18, 7, 3, 2400],
209
+ click_to_chat_link: [12, 5, 1, 900],
210
+ status: [5, 1, 0, 0],
211
+ },
212
+ dailyWon: [
213
+ 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 2, 2, 1, 2, 2, 1, 2,
214
+ 2, 2, 2, 2, 2,
215
+ ],
216
+ dailySpend: [[30, 420_000]],
217
+ },
218
+ {
219
+ siteId: "sample-clinica-sorriso",
220
+ name: "Clínica Sorriso",
221
+ segment: "odontologia",
222
+ adAccount: { adAccountId: "act_2039485761", name: "Clínica Sorriso" },
223
+ pixel: null,
224
+ campaigns: [
225
+ {
226
+ campaignId: "120220000000100001",
227
+ name: "Implantes · Conversas",
228
+ adsets: [
229
+ {
230
+ adsetId: "120220000000010001",
231
+ name: "Adultos 35–60 · Caxias do Sul",
232
+ ads: [
233
+ {
234
+ adId: "120220000000000101",
235
+ name: "Antes e depois implante",
236
+ creativeType: "image",
237
+ sourceApp: "facebook",
238
+ now: [1900, 88, 38, 12, 31200],
239
+ before: [1800, 80, 33, 10, 26000],
240
+ },
241
+ {
242
+ adId: "120220000000000102",
243
+ name: "Depoimento Dra. Ana",
244
+ creativeType: "video",
245
+ sourceApp: "instagram",
246
+ now: [1200, 58, 24, 7, 14400],
247
+ before: [1150, 55, 21, 6, 12600],
248
+ },
249
+ ],
250
+ },
251
+ ],
252
+ },
253
+ ],
254
+ organic: {
255
+ global_search_new_chat: [12, 5, 2, 4800],
256
+ click_to_chat_link: [10, 4, 1, 2400],
257
+ },
258
+ organicBefore: {
259
+ global_search_new_chat: [11, 4, 2, 4400],
260
+ click_to_chat_link: [9, 3, 1, 2300],
261
+ },
262
+ dailyWon: [
263
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 1,
264
+ 1, 1, 1, 1, 0,
265
+ ],
266
+ dailySpend: [[30, 310_000]],
267
+ },
268
+ {
269
+ siteId: "sample-pet-mania",
270
+ name: "Pet Mania",
271
+ segment: "pet shop",
272
+ adAccount: { adAccountId: "act_3049586712", name: "Pet Mania Anúncios" },
273
+ pixel: { pixelId: "3049586712304958", sent: 86, pending: 1 },
274
+ campaigns: [
275
+ {
276
+ campaignId: "120230000000100001",
277
+ name: "Banho e tosa · Conversas",
278
+ adsets: [
279
+ {
280
+ adsetId: "120230000000010001",
281
+ name: "Tutores até 5 km",
282
+ ads: [
283
+ {
284
+ adId: "120230000000000101",
285
+ name: "Banho em 1 hora",
286
+ creativeType: "video",
287
+ sourceApp: "instagram",
288
+ now: [1100, 132, 32, 21, 3990],
289
+ before: [1050, 120, 30, 22, 4180],
290
+ },
291
+ {
292
+ adId: "120230000000000102",
293
+ name: "Promo tosa bebê",
294
+ creativeType: "image",
295
+ sourceApp: "facebook",
296
+ now: [750, 76, 22, 12, 2280],
297
+ before: [700, 70, 20, 12, 2280],
298
+ },
299
+ ],
300
+ },
301
+ ],
302
+ },
303
+ ],
304
+ organic: {
305
+ global_search_new_chat: [20, 6, 4, 760],
306
+ status: [15, 3, 2, 380],
307
+ },
308
+ organicBefore: {
309
+ global_search_new_chat: [18, 5, 3, 570],
310
+ status: [14, 3, 2, 380],
311
+ },
312
+ // The mockup's alert, on weeks whose leads have had time to close (the
313
+ // last 7 days are left out: their leads are still buying): days 9–15
314
+ // sold 9 for R$ 468 (R$ 52 each), days 16–22 sold 7 for R$ 429,80
315
+ // (R$ 61,40): cost per sale +18%.
316
+ dailyWon: [
317
+ 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
318
+ 1, 1, 1, 1, 1,
319
+ ],
320
+ dailySpend: [
321
+ [9, 51_120],
322
+ [7, 46_800],
323
+ [7, 42_980],
324
+ [7, 44_100],
325
+ ],
326
+ },
327
+ {
328
+ siteId: "sample-casa-aurora",
329
+ name: "Casa Aurora",
330
+ segment: "móveis planejados",
331
+ adAccount: {
332
+ adAccountId: "act_4059687123",
333
+ name: "Casa Aurora Planejados",
334
+ },
335
+ pixel: null,
336
+ campaigns: [
337
+ {
338
+ campaignId: "120240000000100001",
339
+ name: "Cozinhas planejadas · Conversas",
340
+ adsets: [
341
+ {
342
+ adsetId: "120240000000010001",
343
+ name: "Casais 30–55 · Porto Alegre",
344
+ ads: [
345
+ {
346
+ adId: "120240000000000101",
347
+ name: "Tour cozinha 30s",
348
+ creativeType: "video",
349
+ sourceApp: "instagram",
350
+ now: [3400, 61, 20, 3, 39000],
351
+ before: [3300, 58, 18, 2, 27000],
352
+ },
353
+ {
354
+ adId: "120240000000000102",
355
+ name: "Carrossel projetos",
356
+ creativeType: "carousel",
357
+ sourceApp: "instagram",
358
+ now: [2000, 36, 11, 1, 13000],
359
+ before: [1900, 30, 10, 1, 12500],
360
+ },
361
+ ],
362
+ },
363
+ ],
364
+ },
365
+ ],
366
+ organic: { click_to_chat_link: [9, 3, 0, 0] },
367
+ organicBefore: { click_to_chat_link: [8, 2, 0, 0] },
368
+ dailyWon: [
369
+ 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
370
+ 0, 0, 1, 0, 0,
371
+ ],
372
+ dailySpend: [[30, 540_000]],
373
+ },
374
+ ];
375
+
376
+ /** `total` split over `weights` in whole units, largest remainder first. */
377
+ function spread(total: number, weights: readonly number[]): number[] {
378
+ const sum = weights.reduce((acc, weight) => acc + weight, 0);
379
+ if (sum <= 0) return weights.map(() => 0);
380
+ const raw = weights.map((weight) => (weight / sum) * total);
381
+ const out = raw.map((value) => Math.floor(value + 1e-9));
382
+ let left = total - out.reduce((acc, value) => acc + value, 0);
383
+ const order = raw
384
+ .map((value, index) => ({ index, rest: value - (out[index] ?? 0) }))
385
+ .sort((a, b) => b.rest - a.rest || a.index - b.index);
386
+ for (const { index } of order) {
387
+ if (left <= 0) break;
388
+ out[index] = (out[index] ?? 0) + 1;
389
+ left -= 1;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ export const AD_ORIGIN = "ctwa_ad";
395
+
396
+ /** Days of a range, oldest first, as `dayStart` timestamps. */
397
+ const daysOf = (range: { from: number }) =>
398
+ Array.from({ length: 30 }, (_, index) => range.from + index * DAY_MS);
399
+ export const DAYS = daysOf(SAMPLE_RANGE);
400
+ const PREVIOUS_DAYS = daysOf(SAMPLE_PREVIOUS_RANGE);
401
+
402
+ /**
403
+ * A daily series that adds up to `figures`: sales on the given days, revenue
404
+ * with them, spend by the given stretches; qualified leads include the sales
405
+ * and leads include the qualified, day by day, as the cohort rollups do.
406
+ */
407
+ function buildDaily(
408
+ figures: Figures,
409
+ won: readonly number[],
410
+ spendCents: readonly number[],
411
+ days: readonly number[] = DAYS,
412
+ revenuePerDay?: readonly number[],
413
+ ): LeadsDaily {
414
+ const [, leads, qualified, sales, revenue] = figures;
415
+ const weights = spendCents.map((cents) => Math.max(cents, 1));
416
+ const extraQualified = spread(qualified - sales, weights);
417
+ const extraLeads = spread(leads - qualified, weights);
418
+ const revenueCents = revenuePerDay ?? spread(revenue * 100, won);
419
+ return days.map((dayStart, index) => {
420
+ const dayWon = won[index] ?? 0;
421
+ const dayQualified = dayWon + (extraQualified[index] ?? 0);
422
+ return {
423
+ dayStart,
424
+ leads: dayQualified + (extraLeads[index] ?? 0),
425
+ qualified: dayQualified,
426
+ won: dayWon,
427
+ revenueCents: revenueCents[index] ?? 0,
428
+ spendCents: spendCents[index] ?? 0,
429
+ };
430
+ });
431
+ }
432
+
433
+ function siteSpendCents(site: SampleSite): number[] {
434
+ const out: number[] = [];
435
+ for (const [days, cents] of site.dailySpend) {
436
+ const weights = site.dailySpend.length === 1 ? SPEND_SHAPE : steady(days);
437
+ out.push(...spread(cents, weights));
438
+ }
439
+ return out;
440
+ }
441
+
442
+ // The drawer's leads for "Vídeo provador 15s" (Directions A and C).
443
+ const PROVADOR_SALES = [
444
+ 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 2, 0,
445
+ 1, 0, 1, 1,
446
+ ];
447
+
448
+ const flatAds = (site: SampleSite) =>
449
+ site.campaigns.flatMap((campaign) =>
450
+ campaign.adsets.flatMap((adset) => adset.ads),
451
+ );
452
+
453
+ /**
454
+ * Hands each day's units (sales) to the rows, one at a time, to the row
455
+ * furthest behind its share of what has been handed so far; a row never
456
+ * passes its total, so every row ends exactly on it.
457
+ */
458
+ function allocateUnits(
459
+ perDay: readonly number[],
460
+ totals: readonly number[],
461
+ ): number[][] {
462
+ const grand = totals.reduce((acc, total) => acc + total, 0);
463
+ const given = totals.map(() => 0);
464
+ const rows = totals.map(() => perDay.map(() => 0));
465
+ let handed = 0;
466
+ perDay.forEach((count, day) => {
467
+ for (let unit = 0; unit < count; unit += 1) {
468
+ handed += 1;
469
+ let best = -1;
470
+ let bestGap = -Infinity;
471
+ totals.forEach((total, index) => {
472
+ const gap = (total * handed) / grand - (given[index] ?? 0);
473
+ if ((given[index] ?? 0) < total && gap > bestGap + 1e-9) {
474
+ best = index;
475
+ bestGap = gap;
476
+ }
477
+ });
478
+ if (best < 0) throw new Error("sample sales split ran out of rows");
479
+ given[best] = (given[best] ?? 0) + 1;
480
+ rows[best]![day] = (rows[best]![day] ?? 0) + 1;
481
+ }
482
+ });
483
+ return rows;
484
+ }
485
+
486
+ /**
487
+ * Splits each day's cents among the rows by their share of the running total,
488
+ * rounded; the largest row takes the rounding, so days and rows both add up.
489
+ */
490
+ function allocateCents(
491
+ perDay: readonly number[],
492
+ totals: readonly number[],
493
+ ): number[][] {
494
+ const grand = totals.reduce((acc, total) => acc + total, 0);
495
+ const largest = totals.indexOf(Math.max(...totals));
496
+ const reached = totals.map(() => 0);
497
+ const rows = totals.map(() => [] as number[]);
498
+ let running = 0;
499
+ for (const cents of perDay) {
500
+ running += cents;
501
+ let assigned = 0;
502
+ totals.forEach((total, index) => {
503
+ if (index === largest) return;
504
+ const target = Math.round((total * running) / grand);
505
+ rows[index]!.push(target - (reached[index] ?? 0));
506
+ assigned += target - (reached[index] ?? 0);
507
+ reached[index] = target;
508
+ });
509
+ rows[largest]!.push(cents - assigned);
510
+ }
511
+ if (rows.some((row) => row.some((cents) => cents < 0))) {
512
+ throw new Error("sample spend split went negative");
513
+ }
514
+ return rows;
515
+ }
516
+
517
+ // Ads whose day-by-day line a mockup drew: kept exactly as drawn.
518
+ const DRAWN_ADS: Record<string, { won: number[]; spendCents: number[] }> = {
519
+ "120210000000000101": {
520
+ won: PROVADOR_SALES,
521
+ spendCents: spread(100_000, SPEND_SHAPE),
522
+ },
523
+ };
524
+
525
+ interface AdSeries {
526
+ now: LeadsDaily;
527
+ previous: LeadsDaily | null;
528
+ }
529
+
530
+ /**
531
+ * Every ad's funnel day by day, adding up to the ad's figures and, summed
532
+ * over the site's ads, to the site's sales and spend of each day.
533
+ */
534
+ function adSeries(site: SampleSite): Map<string, AdSeries> {
535
+ const ads = flatAds(site);
536
+ const drawn = ads.filter((ad) => DRAWN_ADS[ad.adId]);
537
+ const free = ads.filter((ad) => !DRAWN_ADS[ad.adId]);
538
+ const siteSpend = siteSpendCents(site);
539
+ const restWon = site.dailyWon.map(
540
+ (won, day) =>
541
+ won -
542
+ drawn.reduce((acc, ad) => acc + (DRAWN_ADS[ad.adId]?.won[day] ?? 0), 0),
543
+ );
544
+ const restSpend = siteSpend.map(
545
+ (cents, day) =>
546
+ cents -
547
+ drawn.reduce(
548
+ (acc, ad) => acc + (DRAWN_ADS[ad.adId]?.spendCents[day] ?? 0),
549
+ 0,
550
+ ),
551
+ );
552
+ const won = allocateUnits(
553
+ restWon,
554
+ free.map((ad) => ad.now[3]),
555
+ );
556
+ const spend = allocateCents(
557
+ restSpend,
558
+ free.map((ad) => ad.now[0] * 100),
559
+ );
560
+ const series = new Map<string, AdSeries>();
561
+ for (const ad of ads) {
562
+ const index = free.indexOf(ad);
563
+ const line = DRAWN_ADS[ad.adId] ?? {
564
+ won: won[index] ?? [],
565
+ spendCents: spend[index] ?? [],
566
+ };
567
+ series.set(ad.adId, {
568
+ now: buildDaily(
569
+ ad.now,
570
+ line.won,
571
+ line.spendCents,
572
+ DAYS,
573
+ revenueWithNamedSales(ad, line.won),
574
+ ),
575
+ previous: ad.before
576
+ ? buildDaily(
577
+ ad.before,
578
+ spread(ad.before[3], SPEND_SHAPE),
579
+ spread(ad.before[0] * 100, SPEND_SHAPE),
580
+ PREVIOUS_DAYS,
581
+ )
582
+ : null,
583
+ });
584
+ }
585
+ return series;
586
+ }
587
+
588
+ /** The day a lead arrived, as an index into the range's days. */
589
+ const dayIndex = (at: number) => Math.floor((at - SAMPLE_RANGE.from) / DAY_MS);
590
+
591
+ /**
592
+ * An ad's revenue per day when the mockups name some of its sales: each named
593
+ * sale keeps its own value on its day, the rest splits over the other sales.
594
+ */
595
+ function revenueWithNamedSales(
596
+ ad: SampleAd,
597
+ won: readonly number[],
598
+ ): number[] | undefined {
599
+ const named = PROVADOR_LEADS.filter(
600
+ (lead) => lead.adId === ad.adId && lead.stage === "won",
601
+ );
602
+ if (named.length === 0) return undefined;
603
+ const pinned = won.map(() => 0);
604
+ const namedWon = won.map(() => 0);
605
+ for (const lead of named) {
606
+ const day = dayIndex(lead.firstAt);
607
+ pinned[day] = (pinned[day] ?? 0) + lead.revenueCents;
608
+ namedWon[day] = (namedWon[day] ?? 0) + 1;
609
+ }
610
+ const rest = spread(
611
+ ad.now[4] * 100 - pinned.reduce((acc, cents) => acc + cents, 0),
612
+ won.map((count, day) => count - (namedWon[day] ?? 0)),
613
+ );
614
+ return won.map((_, day) => (pinned[day] ?? 0) + (rest[day] ?? 0));
615
+ }
616
+
617
+ /** An origin no ad paid for, spread over the range; it has no spend. */
618
+ const organicSeries = (
619
+ [leads, qualified, won, revenue]: OrganicFigures,
620
+ days: readonly number[],
621
+ ): LeadsDaily =>
622
+ buildDaily(
623
+ [0, leads, qualified, won, revenue],
624
+ spread(won, SPEND_SHAPE),
625
+ SPEND_SHAPE,
626
+ days,
627
+ ).map((row) => ({ ...row, spendCents: null }));
628
+
629
+ const lead = (
630
+ leadId: string,
631
+ label: string,
632
+ stage: LeadSummary["stage"],
633
+ sourceApp: "instagram" | "facebook",
634
+ firstAt: number,
635
+ stageAt: number,
636
+ revenue = 0,
637
+ ): LeadSummary => ({
638
+ leadId,
639
+ siteId: "sample-loja-x",
640
+ label,
641
+ channel: "whatsapp",
642
+ originKey: AD_ORIGIN,
643
+ entryPoint: AD_ORIGIN,
644
+ adId: LOJA_X_AD.provador,
645
+ sourceApp,
646
+ stage,
647
+ revenueCents: revenue * 100,
648
+ firstAt,
649
+ stageAt,
650
+ hasVisitor: false,
651
+ });
652
+
653
+ export const MARINA = lead(
654
+ "sample-lead-marina",
655
+ "Marina C.",
656
+ "won",
657
+ "instagram",
658
+ brt(9, 26, 10, 41),
659
+ brt(9, 28, 9, 15),
660
+ 890,
661
+ );
662
+
663
+ // Newest arrival first, the order `leads.list` answers in.
664
+ export const PROVADOR_LEADS: LeadSummary[] = [
665
+ MARINA,
666
+ lead(
667
+ "sample-lead-paula",
668
+ "Paula R.",
669
+ "qualified",
670
+ "instagram",
671
+ brt(9, 27, 19, 30),
672
+ brt(9, 28, 6, 15),
673
+ ),
674
+ lead(
675
+ "sample-lead-julia",
676
+ "Júlia S.",
677
+ "won",
678
+ "facebook",
679
+ brt(9, 22, 14, 10),
680
+ brt(9, 27, 16, 2),
681
+ 1240,
682
+ ),
683
+ lead(
684
+ "sample-lead-carla",
685
+ "Carla M.",
686
+ "won",
687
+ "instagram",
688
+ brt(9, 24, 18, 5),
689
+ brt(9, 27, 10, 30),
690
+ 1020,
691
+ ),
692
+ lead(
693
+ "sample-lead-bruna",
694
+ "Bruna T.",
695
+ "lead",
696
+ "instagram",
697
+ brt(9, 27, 15, 20),
698
+ brt(9, 27, 15, 20),
699
+ ),
700
+ ].sort((left, right) => right.firstAt - left.firstAt);
701
+
702
+ const SERIES = new Map(SITES.map((site) => [site.siteId, adSeries(site)]));
703
+ export const seriesOf = (site: SampleSite) =>
704
+ SERIES.get(site.siteId) ?? new Map<string, AdSeries>();
705
+
706
+ /**
707
+ * What the example host's `seedDemo` writes so the demo dashboard shows these
708
+ * same reports: each site with its ad account, pixel and catalog, every ad's
709
+ * funnel and spend day by day (this period and, where the mockups have one,
710
+ * the previous), and each organic origin day by day. Leads count on the day
711
+ * they arrived and carry their later stages there, as the cohort rollups do;
712
+ * the day's sales hold the day's revenue.
713
+ */
714
+ export const SAMPLE_SEED = SITES.map((site) => {
715
+ const series = seriesOf(site);
716
+ return {
717
+ siteId: site.siteId,
718
+ name: site.name,
719
+ segment: site.segment,
720
+ currency: "BRL",
721
+ adAccount: {
722
+ ...site.adAccount,
723
+ currency: "BRL",
724
+ timezone: "America/Sao_Paulo",
725
+ },
726
+ pixelId: site.pixel?.pixelId ?? null,
727
+ campaigns: site.campaigns.map((campaign) => ({
728
+ campaignId: campaign.campaignId,
729
+ name: campaign.name,
730
+ adsets: campaign.adsets.map((adset) => ({
731
+ adsetId: adset.adsetId,
732
+ name: adset.name,
733
+ ads: adset.ads.map((ad) => ({
734
+ adId: ad.adId,
735
+ name: ad.name,
736
+ creativeType: ad.creativeType,
737
+ sourceApp: ad.sourceApp,
738
+ adStatus: "ACTIVE",
739
+ daily: series.get(ad.adId)?.now ?? [],
740
+ previousDaily: series.get(ad.adId)?.previous ?? null,
741
+ })),
742
+ })),
743
+ })),
744
+ organic: Object.entries(site.organic).map(([key, figures]) => ({
745
+ key,
746
+ daily: organicSeries(figures, DAYS),
747
+ })),
748
+ previousOrganic: Object.entries(site.organicBefore ?? {}).map(
749
+ ([key, figures]) => ({
750
+ key,
751
+ daily: organicSeries(figures, PREVIOUS_DAYS),
752
+ }),
753
+ ),
754
+ };
755
+ });