aimeat 1.23.2 → 1.23.3

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 (154) hide show
  1. package/dist/locales/en.json +536 -21
  2. package/dist/locales/fi.json +548 -33
  3. package/dist/public/components/ContactCard.js +28 -0
  4. package/dist/public/css/views/admin.css +12 -0
  5. package/dist/public/css/views/agents-detail.css +62 -10
  6. package/dist/public/css/views/landing.css +87 -0
  7. package/dist/public/css/views/offers.css +96 -0
  8. package/dist/public/css/views/portfolio.css +18 -0
  9. package/dist/public/css/views/profile.css +153 -8
  10. package/dist/public/css/views/start.css +96 -0
  11. package/dist/public/img/business-hero.png +0 -0
  12. package/dist/public/img/toimitus.png +0 -0
  13. package/dist/public/js/components/tag-cloud.js +46 -31
  14. package/dist/public/js/components/tag-editor.js +2 -2
  15. package/dist/public/js/recents.js +40 -0
  16. package/dist/public/js/services/admin.js +6 -0
  17. package/dist/public/js/services/agents.js +34 -0
  18. package/dist/public/js/services/offers.js +152 -0
  19. package/dist/public/js/services/packages.js +1 -0
  20. package/dist/public/spa.html +32 -2
  21. package/dist/public/views/admin/subdomains-tab.js +182 -0
  22. package/dist/public/views/admin.js +2 -0
  23. package/dist/public/views/business.js +119 -0
  24. package/dist/public/views/how-it-works.js +159 -0
  25. package/dist/public/views/landing.js +235 -0
  26. package/dist/public/views/portfolio.js +120 -23
  27. package/dist/public/views/pricing.js +75 -0
  28. package/dist/public/views/profile/access-tab.js +124 -51
  29. package/dist/public/views/profile/agents/agent-card.js +68 -37
  30. package/dist/public/views/profile/agents/shared-board.js +56 -22
  31. package/dist/public/views/profile/agents/tab-activity.js +24 -12
  32. package/dist/public/views/profile/agents/tab-agent-config.js +31 -1
  33. package/dist/public/views/profile/agents/tab-data-access.js +9 -13
  34. package/dist/public/views/profile/agents/tab-integration.js +27 -1
  35. package/dist/public/views/profile/agents/tab-messages.js +6 -11
  36. package/dist/public/views/profile/agents/tab-quality.js +45 -5
  37. package/dist/public/views/profile/agents-tab.js +41 -6
  38. package/dist/public/views/profile/apps-tab.js +7 -1
  39. package/dist/public/views/profile/knowledge-tab.js +4 -1
  40. package/dist/public/views/profile/landing-page.js +427 -123
  41. package/dist/public/views/profile/memory-tab.js +409 -211
  42. package/dist/public/views/profile/nodes-tab.js +18 -3
  43. package/dist/public/views/profile/offers-tab.js +310 -0
  44. package/dist/public/views/profile/organisms-tab.js +289 -28
  45. package/dist/public/views/profile/packages-tab.js +152 -16
  46. package/dist/public/views/profile/portfolio-tab.js +98 -31
  47. package/dist/public/views/profile.js +2 -0
  48. package/dist/public/views/start-flows.js +281 -0
  49. package/dist/public/views/start.js +258 -0
  50. package/dist/src/generated/api-types.d.ts +856 -17
  51. package/dist/src/generated/api-types.d.ts.map +1 -1
  52. package/dist/src/generated/prisma-postgres/edge.js +15 -3
  53. package/dist/src/generated/prisma-postgres/index-browser.js +12 -0
  54. package/dist/src/generated/prisma-postgres/index.d.ts +1476 -160
  55. package/dist/src/generated/prisma-postgres/index.js +15 -3
  56. package/dist/src/generated/prisma-postgres/package.json +1 -1
  57. package/dist/src/generated/prisma-postgres/schema.prisma +11 -0
  58. package/dist/src/generated/prisma-postgres/wasm.js +15 -3
  59. package/dist/src/middleware/subdomain.d.ts +28 -0
  60. package/dist/src/middleware/subdomain.d.ts.map +1 -0
  61. package/dist/src/middleware/subdomain.js +32 -0
  62. package/dist/src/middleware/subdomain.js.map +1 -0
  63. package/dist/src/models/offer-schemas.d.ts +229 -0
  64. package/dist/src/models/offer-schemas.d.ts.map +1 -0
  65. package/dist/src/models/offer-schemas.js +100 -0
  66. package/dist/src/models/offer-schemas.js.map +1 -0
  67. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  68. package/dist/src/routes/agent-tasks.js +33 -0
  69. package/dist/src/routes/agent-tasks.js.map +1 -1
  70. package/dist/src/routes/agents.d.ts.map +1 -1
  71. package/dist/src/routes/agents.js +146 -1
  72. package/dist/src/routes/agents.js.map +1 -1
  73. package/dist/src/routes/apps-backup.d.ts +20 -0
  74. package/dist/src/routes/apps-backup.d.ts.map +1 -0
  75. package/dist/src/routes/apps-backup.js +165 -0
  76. package/dist/src/routes/apps-backup.js.map +1 -0
  77. package/dist/src/routes/ghii.d.ts.map +1 -1
  78. package/dist/src/routes/ghii.js +4 -0
  79. package/dist/src/routes/ghii.js.map +1 -1
  80. package/dist/src/routes/instances.d.ts +2 -1
  81. package/dist/src/routes/instances.d.ts.map +1 -1
  82. package/dist/src/routes/instances.js +59 -1
  83. package/dist/src/routes/instances.js.map +1 -1
  84. package/dist/src/routes/packages.d.ts.map +1 -1
  85. package/dist/src/routes/packages.js +32 -0
  86. package/dist/src/routes/packages.js.map +1 -1
  87. package/dist/src/routes/permissions.d.ts +12 -0
  88. package/dist/src/routes/permissions.d.ts.map +1 -1
  89. package/dist/src/routes/permissions.js +35 -3
  90. package/dist/src/routes/permissions.js.map +1 -1
  91. package/dist/src/routes/portal.d.ts +3 -0
  92. package/dist/src/routes/portal.d.ts.map +1 -1
  93. package/dist/src/routes/portal.js +12 -0
  94. package/dist/src/routes/portal.js.map +1 -1
  95. package/dist/src/routes/public-stats.d.ts +15 -0
  96. package/dist/src/routes/public-stats.d.ts.map +1 -0
  97. package/dist/src/routes/public-stats.js +90 -0
  98. package/dist/src/routes/public-stats.js.map +1 -0
  99. package/dist/src/routes/sse.d.ts +7 -2
  100. package/dist/src/routes/sse.d.ts.map +1 -1
  101. package/dist/src/routes/sse.js +35 -5
  102. package/dist/src/routes/sse.js.map +1 -1
  103. package/dist/src/routes/subdomains.d.ts +33 -0
  104. package/dist/src/routes/subdomains.d.ts.map +1 -0
  105. package/dist/src/routes/subdomains.js +208 -0
  106. package/dist/src/routes/subdomains.js.map +1 -0
  107. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  108. package/dist/src/server-bootstrap/routes-loader.js +12 -1
  109. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  110. package/dist/src/server.d.ts.map +1 -1
  111. package/dist/src/server.js +4 -0
  112. package/dist/src/server.js.map +1 -1
  113. package/dist/src/services/apps-backup-export.d.ts +22 -0
  114. package/dist/src/services/apps-backup-export.d.ts.map +1 -0
  115. package/dist/src/services/apps-backup-export.js +136 -0
  116. package/dist/src/services/apps-backup-export.js.map +1 -0
  117. package/dist/src/services/apps-backup-import.d.ts +120 -0
  118. package/dist/src/services/apps-backup-import.d.ts.map +1 -0
  119. package/dist/src/services/apps-backup-import.js +259 -0
  120. package/dist/src/services/apps-backup-import.js.map +1 -0
  121. package/dist/src/services/component-registrar.d.ts +17 -0
  122. package/dist/src/services/component-registrar.d.ts.map +1 -1
  123. package/dist/src/services/component-registrar.js +63 -5
  124. package/dist/src/services/component-registrar.js.map +1 -1
  125. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  126. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  127. package/dist/src/services/handbooks/appdev.js +5 -2
  128. package/dist/src/services/handbooks/appdev.js.map +1 -1
  129. package/dist/src/storage/interface.d.ts +11 -1
  130. package/dist/src/storage/interface.d.ts.map +1 -1
  131. package/dist/src/storage/interface.js.map +1 -1
  132. package/dist/src/storage/providers/mongodb/index.d.ts +10 -1
  133. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  134. package/dist/src/storage/providers/mongodb/index.js +76 -0
  135. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  136. package/dist/src/storage/providers/sqlite/index.d.ts +8 -1
  137. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  138. package/dist/src/storage/providers/sqlite/index.js +61 -0
  139. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  140. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  141. package/dist/src/storage/providers/sqlite/schema.js +11 -0
  142. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  143. package/dist/src/storage/repositories/index.d.ts +1 -0
  144. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  145. package/dist/src/storage/repositories/package.repository.d.ts +2 -0
  146. package/dist/src/storage/repositories/package.repository.d.ts.map +1 -1
  147. package/dist/src/storage/repositories/subdomain-site.repository.d.ts +19 -0
  148. package/dist/src/storage/repositories/subdomain-site.repository.d.ts.map +1 -0
  149. package/dist/src/storage/repositories/subdomain-site.repository.js +2 -0
  150. package/dist/src/storage/repositories/subdomain-site.repository.js.map +1 -0
  151. package/dist/static/app-catalog.html +1139 -28
  152. package/package.json +1 -1
  153. package/prisma/schema.postgres.prisma +11 -0
  154. package/prisma/schema.prisma +11 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file landing.js
3
+ * @description Logged-out landing page: one story, proven live. Hero → live activity
4
+ * ticker → today's real node stats + ownership line (the sales core) → three audience
5
+ * path cards → the 3-step prompt loop → proof gallery of real public apps → footer.
6
+ * Logged-in visitors are forwarded to the profile Home dashboard. No protocol terms
7
+ * (GHII/GAII/CSM/federation) in the hero or path cards; numbers do the selling.
8
+ * @structure default export Landing({ navigate }) + Ticker/StatsPanel/PathCards/Gallery
9
+ * @usage routed at /v1/portal (and '/' for browsers) by spa.html
10
+ * @version-history
11
+ * v1.0.0 — 2026-06-10 — Initial: landing/portal split (owner spec).
12
+ */
13
+ import { h } from 'preact';
14
+ import { useState, useEffect } from 'preact/hooks';
15
+ import htm from 'htm';
16
+ const html = htm.bind(h);
17
+ import { t } from '/js/i18n.js';
18
+ import { escHtml } from '/js/utils.js';
19
+
20
+ // t() echoes the key when a translation is missing — fall back to readable English.
21
+ const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
22
+
23
+ const relMin = (iso) => {
24
+ const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
25
+ if (mins < 60) return (tr('landing.minAgo', '{n} min ago')).replace('{n}', String(mins));
26
+ return (tr('landing.hAgo', '{n} h ago')).replace('{n}', String(Math.round(mins / 60)));
27
+ };
28
+
29
+ /* ── Live ticker: one line, refreshed every 10 s from real public activity. ── */
30
+ function Ticker() {
31
+ const [data, setData] = useState(null);
32
+ useEffect(() => {
33
+ let alive = true;
34
+ const load = () => fetch('/v1/public/activity-ticker').then(r => r.json())
35
+ .then(j => { if (alive && j?.ok !== false) setData(j.data); })
36
+ .catch(() => { /* static fallback stays */ });
37
+ load();
38
+ const iv = setInterval(load, 10_000);
39
+ return () => { alive = false; clearInterval(iv); };
40
+ }, []);
41
+
42
+ // Event → human sentence WHITELIST. Raw key names never reach the visitor;
43
+ // unknown event shapes are dropped entirely (no fallback to raw data).
44
+ const humanize = (item) => {
45
+ const actor = item.actor || '';
46
+ const key = item.key || '';
47
+ if (/statistics|last-init|timestamp|config|cache|readme|\.ui$/i.test(key)) return null;
48
+ if (actor.startsWith('ext:')) {
49
+ const name = actor.slice(4).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
50
+ return `${name} ${tr('landing.evExtension', 'published an update')}`;
51
+ }
52
+ if (/news|article|sanomat|notes|\bdoc|d-/i.test(key)) return tr('landing.evDocument', 'A new document was published');
53
+ if (/task/i.test(key)) return tr('landing.evTask', 'An agent completed a task');
54
+ if (/schedule|cron|daily/i.test(key)) return tr('landing.evSchedule', 'A scheduled job ran');
55
+ return null;
56
+ };
57
+ // Freshness rule: a "live" ticker showing a 45-hour-old event undermines the claim.
58
+ // Only events from the last 24 h qualify; with none, drop the ticker entirely and
59
+ // let the stats panel below carry the message. The static fallback is reserved for
60
+ // the endpoint-not-responding case (data === null).
61
+ const FRESH_MS = 24 * 3600 * 1000;
62
+ const hit = (data?.items || [])
63
+ .filter(i => i.at && Date.now() - new Date(i.at).getTime() < FRESH_MS)
64
+ .map(i => ({ i, txt: humanize(i) })).find(x => x.txt);
65
+ if (data && !hit) return null;
66
+ const agentsBit = data?.agents_online > 0
67
+ ? ` · ${data.agents_online} ${data.agents_online === 1 ? tr('landing.agentOnlineOne', 'agent online') : tr('landing.agentsOnline', 'agents online')}`
68
+ : '';
69
+ return html`
70
+ <div class="ld-ticker" title=${tr('landing.tickerTitle', 'Live activity on this node')}>
71
+ <span class="ld-ticker-dot">●</span>
72
+ ${hit
73
+ ? html`<span class="ld-ticker-text">${escHtml(hit.txt)} · ${relMin(hit.i.at)}${agentsBit}</span>`
74
+ : html`<span class="ld-ticker-text">${tr('landing.tickerFallback', 'Agents on this node write, schedule and publish around the clock.')}${agentsBit}</span>`}
75
+ </div>
76
+ `;
77
+ }
78
+
79
+ /* ── Today's stats + ownership line — THE sales core. Real numbers; zeros are omitted. ── */
80
+ function StatsPanel({ navigate }) {
81
+ const [stats, setStats] = useState(null);
82
+ useEffect(() => {
83
+ fetch('/v1/public/node-stats-today').then(r => r.json())
84
+ .then(j => { if (j?.ok !== false) setStats(j.data); })
85
+ .catch(() => { /* fallback line stays */ });
86
+ }, []);
87
+
88
+ const parts = [];
89
+ if (stats?.public_writes > 0) parts.push(`${stats.public_writes} ${tr('landing.statWrites', 'public entries written')}`);
90
+ if (stats?.tasks_completed > 0) parts.push(`${stats.tasks_completed} ${tr('landing.statTasks', 'tasks completed')}`);
91
+ if (stats?.schedules_fired > 0) parts.push(`${stats.schedules_fired} ${tr('landing.statSchedules', 'schedules fired')}`);
92
+
93
+ return html`
94
+ <div class="ld-stats">
95
+ <div class="ld-stats-line">
96
+ ${parts.length > 0
97
+ ? html`${tr('landing.todayPrefix', 'This node today:')} ${parts.join(' · ')} · 0 ${tr('landing.humanHours', 'human hours')}`
98
+ : tr('landing.statsFallback', 'This node runs agents around the clock — schedules, tasks and publishing without human hours.')}
99
+ </div>
100
+ <div class="ld-stats-own">
101
+ ${tr('landing.ownLine', 'The same could run for you. Your own node, your data, your agents.')}
102
+ <a class="ld-stats-cta" href="/v1/pricing" onClick=${(e) => { e.preventDefault(); navigate('/v1/pricing'); }}>
103
+ ${tr('landing.ownCta', 'From 49 €/mo →')}
104
+ </a>
105
+ </div>
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ /* ── Proof gallery: six fully curated cards. Sanomat and Comicland lead because they
111
+ work solo, instantly; Deep Six needs two players (badged). Proof, not a shop. ── */
112
+ function Gallery({ onApps }) {
113
+ const [, setApps] = useState([]);
114
+ useEffect(() => {
115
+ // The catalog still feeds card A's local-Sanomat lookup via onApps.
116
+ fetch('/v1/apps?sort=popular&limit=50').then(r => r.json())
117
+ .then(j => { const list = j?.data?.apps || []; setApps(list); onApps?.(list); })
118
+ .catch(() => { /* curated cards render regardless */ });
119
+ }, []);
120
+
121
+ const cards = [
122
+ { name: 'AIMEAT Sanomat', desc: tr('landing.gallerySanomat', 'The paper that writes itself every evening. Six agents, zero human hours.'),
123
+ href: 'https://aimeat.io/v1/apps/happydude500001/laimeat-sanomat.html?mode=inline' },
124
+ { name: 'Comicland', desc: tr('landing.galleryComicland', 'Comics by agents — browse the catalog.'),
125
+ href: 'https://aimeat.io/v1/apps/happydude500001/comicland-v2-app.html?mode=inline#/catalog' },
126
+ { name: 'Battleship (Deep Six)', desc: tr('landing.galleryDeepSix', 'Two-player battleship on an agent platform. Bring an opponent — or two browsers.'),
127
+ href: 'https://aimeat.io/v1/apps/anonymous/deep-six.html?mode=inline', badge: tr('landing.twoPlayers', '2 players') },
128
+ { name: 'Solar System Simulator', desc: tr('landing.gallerySolar', 'Simulate the solar system.'),
129
+ href: 'https://aimeat.io/v1/apps/anonymous/solar-system.html?mode=inline' },
130
+ { name: 'Math Graph 3D', desc: tr('landing.galleryGraph', 'Plot 3D math functions in the browser.'),
131
+ href: 'https://aimeat.io/v1/apps/anonymous/graph.html?mode=inline' },
132
+ { name: 'Band Jam', desc: tr('landing.galleryBandJam', 'Make music with your friends, live!'),
133
+ href: 'https://aimeat.io/v1/apps/anonymous/band-jam.html?mode=inline' },
134
+ ];
135
+
136
+ return html`
137
+ <div class="ld-section">
138
+ <h2 class="ld-h2">${tr('landing.galleryTitle', 'Built with this loop — real apps, try them')}</h2>
139
+ <div class="ld-gallery">
140
+ ${cards.map(c => html`
141
+ <a key=${c.href} class="ld-app-card" href=${c.href} target="_blank" rel="noopener">
142
+ <div class="ld-app-name">${escHtml(c.name)}${c.badge && html` <span class="ld-app-badge">${c.badge}</span>`}</div>
143
+ ${c.desc && html`<div class="ld-app-desc">${escHtml(c.desc)}</div>`}
144
+ <div class="ld-app-meta">${tr('landing.builtInChat', 'built in an AI chat session')}</div>
145
+ </a>
146
+ `)}
147
+ </div>
148
+ </div>
149
+ `;
150
+ }
151
+
152
+ export default function Landing({ navigate }) {
153
+ // Logged-in users arriving DIRECTLY (bookmark, external link, address bar) go straight
154
+ // to the Home dashboard. But a deliberate in-app navigation here (brand link, footer)
155
+ // shows the landing — otherwise a logged-in user could never see this page at all.
156
+ // The in-app flag is set by spa.html's handleNav (sessionStorage, per browser tab).
157
+ useEffect(() => {
158
+ try { if (sessionStorage.getItem('aimeat.in-app') === '1') return undefined; } catch { /* fall through */ }
159
+ const check = () => {
160
+ try {
161
+ const raw = localStorage.getItem('aimeat_session');
162
+ if (raw && JSON.parse(raw)?.jwt) { navigate('/v1/profile'); return true; }
163
+ } catch { /* stay on landing */ }
164
+ return false;
165
+ };
166
+ if (check()) return undefined;
167
+ const onAuth = () => check();
168
+ window.addEventListener('aimeat-auth-change', onAuth);
169
+ return () => window.removeEventListener('aimeat-auth-change', onAuth);
170
+ }, []);
171
+
172
+ const [apps, setApps] = useState([]);
173
+ // Card A sends the solo visitor to Sanomat — an instant experience alone, no login.
174
+ // (Battleship/Deep Six lives in the gallery with its "2 players" badge.)
175
+ const sanomat = apps.find(a => /sanomat/i.test((a.filename || '') + ' ' + (a.name || '')));
176
+ const tryHref = sanomat
177
+ ? `/v1/apps/${encodeURIComponent(sanomat.owner)}/${encodeURIComponent(sanomat.filename)}?mode=inline`
178
+ : 'https://aimeat.io/v1/apps/happydude500001/laimeat-sanomat.html?mode=inline';
179
+
180
+ return html`
181
+ <div class="ld">
182
+ <!-- 1. Hero — no protocol terms here. -->
183
+ <section class="ld-hero">
184
+ <h1 class="ld-h1">${tr('landing.heroTitle', 'Your AI gets memory, agents and a place to build.')}</h1>
185
+ <p class="ld-hero-sub">${tr('landing.heroSub', 'Open protocol. Run your own node or use ours.')}</p>
186
+ </section>
187
+
188
+ <!-- 2. Live ticker -->
189
+ <${Ticker} />
190
+
191
+ <!-- 3. Today's stats + ownership line -->
192
+ <${StatsPanel} navigate=${navigate} />
193
+
194
+ <!-- 4. Three path cards -->
195
+ <div class="ld-paths">
196
+ <div class="ld-path">
197
+ <h3>${tr('landing.cardATitle', 'Try the apps')}</h3>
198
+ <p>${tr('landing.cardAText', 'Play instantly, no login. All built with an AI chat.')}</p>
199
+ <a class="ld-path-cta" href=${tryHref} target="_blank">${tr('landing.cardACta', 'Read tonight’s paper →')}</a>
200
+ </div>
201
+ <div class="ld-path ld-path--hot">
202
+ <h3>${tr('landing.cardBTitle', 'Build with your AI chat')}</h3>
203
+ <p>${tr('landing.cardBText', 'Claude or ChatGPT interviews you and builds the app. AIMEAT is the server.')}</p>
204
+ <a class="ld-path-cta" href="/v1/classic" onClick=${(e) => { e.preventDefault(); navigate('/v1/classic'); }}>${tr('landing.cardBCta', 'Start building →')}</a>
205
+ </div>
206
+ <div class="ld-path">
207
+ <h3>${tr('landing.cardCTitle', 'A digital employee that never sleeps')}</h3>
208
+ <p>${tr('landing.cardCText', 'Scheduled agents fetch, write and report while you work. Ready-made packages for small businesses.')}</p>
209
+ <a class="ld-path-cta" href="/v1/business" onClick=${(e) => { e.preventDefault(); navigate('/v1/business'); }}>${tr('landing.cardCCta', 'See packages →')}</a>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- 5. The prompt loop -->
214
+ <div class="ld-loop">
215
+ <span class="ld-loop-step">① ${tr('landing.loop1', 'Copy the prompt into your AI chat')}</span>
216
+ <span class="ld-loop-arrow">→</span>
217
+ <span class="ld-loop-step">② ${tr('landing.loop2', 'The AI interviews you and builds the app')}</span>
218
+ <span class="ld-loop-arrow">→</span>
219
+ <span class="ld-loop-step">③ ${tr('landing.loop3', 'The app lives on your node. Share the link.')}</span>
220
+ </div>
221
+
222
+ <!-- 6. Proof gallery -->
223
+ <${Gallery} onApps=${setApps} />
224
+
225
+ <!-- 7. Footer -->
226
+ <footer class="ld-footer">
227
+ <a href="/v1/pricing" onClick=${(e) => { e.preventDefault(); navigate('/v1/pricing'); }}>${tr('landing.footPricing', 'Pricing')}</a>
228
+ <a href="/v1/guides">${tr('landing.footDocs', 'Docs')}</a>
229
+ <a href="/v1/pricing#own-node" onClick=${(e) => { e.preventDefault(); navigate('/v1/pricing'); }}>${tr('landing.footOwnNode', 'Run your own node')}</a>
230
+ <a href="https://github.com/aimeat-protocol" target="_blank" rel="noopener">GitHub</a>
231
+ <a href="/v1/portal?view=dev">${tr('landing.footDev', 'For developers')}</a>
232
+ </footer>
233
+ </div>
234
+ `;
235
+ }
@@ -3,6 +3,12 @@
3
3
  * @description Portfolio view — builder (select content, generate AI prompt,
4
4
  * upload HTML) and public viewer (sandboxed iframe render).
5
5
  * @version-history
6
+ * v1.2.0 — 2026-06-10 — Workflow round: ①②③ flow banner explains the AI-chat round-trip;
7
+ * group summaries show selected/total + select-all checkbox; Generate disabled with a hint
8
+ * when nothing is selected; "Custom" type and custom auth-gate open describe-textareas that
9
+ * feed the prompt; prompt output shows a character count; step 5 shows the publish URL,
10
+ * gains a sandboxed preview (same allow-scripts srcdoc sandbox as the public viewer) and an
11
+ * Unpublish in the published banner; bottom button says Profile (where it actually goes).
6
12
  * v1.1.0 — 2026-06-02 — Migrate bespoke prompt-copy button to canonical CopyButton component
7
13
  */
8
14
  import { h } from 'preact';
@@ -11,6 +17,10 @@ import htm from 'htm';
11
17
  const html = htm.bind(h);
12
18
  import { t } from '/js/i18n.js';
13
19
  import { escHtml, handleImgError } from '/js/utils.js';
20
+
21
+ // t() echoes the key when a translation is missing (e.g. a server still serving
22
+ // pre-update locales) — fall back to readable English instead of raw keys.
23
+ const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
14
24
  import { apiGet, apiPut } from '/js/api.js';
15
25
  import TagCloud from '/js/components/tag-cloud.js';
16
26
  import { CopyButton } from '/components/CopyButton.js';
@@ -65,7 +75,7 @@ function formatMemoryKey(key) {
65
75
  }
66
76
 
67
77
  /* ── Prompt Builder ── */
68
- function buildPortfolioPrompt({ session, catalog, selectedImages, selectedApps, selectedBoards, selectedCortex, selectedMemories, portfolioType, designStyle, authGates }) {
78
+ function buildPortfolioPrompt({ session, catalog, selectedImages, selectedApps, selectedBoards, selectedCortex, selectedMemories, portfolioType, designStyle, authGates, customTypeDesc, customGateDesc }) {
69
79
  const ghii = session.ghii || (session.owner + '@unknown');
70
80
  const url = NODE_URL;
71
81
 
@@ -132,13 +142,13 @@ The user wants to create a personal portfolio website. Generate a single, self-c
132
142
 
133
143
  prompt += `
134
144
  ## Portfolio Requirements
135
- - Type: ${typeLabels[portfolioType] || portfolioType}
145
+ - Type: ${typeLabels[portfolioType] || portfolioType}${portfolioType === 'custom' && customTypeDesc ? ` — ${customTypeDesc}` : ''}
136
146
  - Design Style: ${styleLabels[designStyle] || designStyle}
137
147
  `;
138
148
 
139
149
  // Auth-gated sections
140
150
  if (authGates.length > 0) {
141
- const gateLabels = { contact: 'Contact information', projectDetails: 'Project details', downloads: 'Download links', custom: 'Custom sections (ask user)' };
151
+ const gateLabels = { contact: 'Contact information', projectDetails: 'Project details', downloads: 'Download links', custom: customGateDesc ? `Custom sections: ${customGateDesc}` : 'Custom sections (ask user)' };
142
152
  prompt += `- Auth-gated sections (show only to logged-in viewers):\n`;
143
153
  for (const gate of authGates) {
144
154
  prompt += ` - ${gateLabels[gate] || gate}\n`;
@@ -282,6 +292,30 @@ function PortfolioBuilder({ session, navigate }) {
282
292
  });
283
293
  };
284
294
 
295
+ // Custom descriptions (step 2 "Custom" type / step 3 "Custom sections")
296
+ const [customTypeDesc, setCustomTypeDesc] = useState('');
297
+ const [customGateDesc, setCustomGateDesc] = useState('');
298
+
299
+ // Group <summary> with selected/total + a select-all checkbox. The checkbox click
300
+ // must not toggle the <details> open state (stopPropagation + preventDefault on the
301
+ // summary's default toggle is handled by stopping the click at the input).
302
+ const groupSummary = (label, ids, selectedSet, setter) => {
303
+ const selCount = ids.filter(id => selectedSet.has(id)).length;
304
+ const all = ids.length > 0 && selCount === ids.length;
305
+ return html`
306
+ <summary>
307
+ <input type="checkbox" class="portfolio-group-checkall" checked=${all}
308
+ title=${tr('portfolio.builder.selectAll', 'Select all')}
309
+ onClick=${(e) => e.stopPropagation()}
310
+ onChange=${() => setter(prev => {
311
+ const next = new Set(prev);
312
+ if (all) ids.forEach(i => next.delete(i)); else ids.forEach(i => next.add(i));
313
+ return next;
314
+ })} />
315
+ ${label} — ${tr('portfolio.builder.selectedOf', '{sel}/{total} selected').replace('{sel}', String(selCount)).replace('{total}', String(ids.length))}
316
+ </summary>`;
317
+ };
318
+
285
319
  // Generate prompt
286
320
  const handleGenerate = () => {
287
321
  if (!catalog) return;
@@ -302,6 +336,8 @@ function PortfolioBuilder({ session, navigate }) {
302
336
  portfolioType,
303
337
  designStyle,
304
338
  authGates: [...authGates],
339
+ customTypeDesc: customTypeDesc.trim(),
340
+ customGateDesc: customGateDesc.trim(),
305
341
  });
306
342
  setGeneratedPrompt(prompt);
307
343
 
@@ -333,11 +369,12 @@ function PortfolioBuilder({ session, navigate }) {
333
369
 
334
370
  // Paste HTML
335
371
  const [pastedHtml, setPastedHtml] = useState('');
372
+ const [showPreview, setShowPreview] = useState(false);
336
373
 
337
374
  const handlePublishPaste = async () => {
338
375
  const text = pastedHtml.trim();
339
376
  if (!text) {
340
- setUploadStatus({ ok: false, msg: t('portfolio.builder.pasteEmpty') || 'Paste HTML content first' });
377
+ setUploadStatus({ ok: false, msg: tr('portfolio.builder.pasteEmpty', 'Paste HTML content first') });
341
378
  return;
342
379
  }
343
380
  await publishHtml(text);
@@ -384,6 +421,19 @@ function PortfolioBuilder({ session, navigate }) {
384
421
  setUploading(false);
385
422
  };
386
423
 
424
+ // Unpublish: flip enabled off — the public route 404s until the next publish.
425
+ const handleUnpublish = async () => {
426
+ setUploading(true);
427
+ try {
428
+ await apiPut('/v1/portfolio/config', { ...(existingConfig || {}), enabled: false, tags: ['portfolio'] });
429
+ setExistingConfig({ ...(existingConfig || {}), enabled: false });
430
+ setUploadStatus({ ok: true, msg: tr('portfolio.builder.unpublished', 'Portfolio unpublished') });
431
+ } catch (err) {
432
+ setUploadStatus({ ok: false, msg: err.message || 'Failed' });
433
+ }
434
+ setUploading(false);
435
+ };
436
+
387
437
  // Upload HTML file (reads text from file, then publishes)
388
438
  const handleUpload = async (file) => {
389
439
  if (!file || !file.name.endsWith('.html')) {
@@ -408,16 +458,32 @@ function PortfolioBuilder({ session, navigate }) {
408
458
 
409
459
  const hasContent = catalog && (catalog.images.length || catalog.apps.length || catalog.boards.length || catalog.cortex.length || catalog.memories.length);
410
460
 
461
+ const totalSelected = selectedImages.size + selectedApps.size + selectedBoards.size + selectedCortex.size + selectedMemories.size;
462
+ const publicUrl = `${NODE_URL}/v1/portfolio/${encodeURIComponent(session.owner)}`;
463
+
411
464
  return html`
412
465
  <div class="portfolio-container">
413
466
  <h1>${t('portfolio.builder.heading')}</h1>
414
- <p style="color:var(--text-dim); margin-bottom:2rem;">${t('portfolio.builder.subtitle')}</p>
467
+ <p class="portfolio-subtitle-text">${t('portfolio.builder.subtitle')}</p>
468
+
469
+ <!-- The loop is the whole point: AIMEAT composes a prompt, YOUR AI chat builds the
470
+ HTML, you paste it back. Without this banner step 1 gives no hint of steps 4-5. -->
471
+ <div class="portfolio-flow">
472
+ <span class="portfolio-flow-step"><span class="portfolio-flow-num">①</span> ${tr('portfolio.builder.flow1', 'Select your content and style')}</span>
473
+ <span class="portfolio-flow-arrow">→</span>
474
+ <span class="portfolio-flow-step"><span class="portfolio-flow-num">②</span> ${tr('portfolio.builder.flow2', 'Generate a prompt and run it in your AI chat (Claude, ChatGPT, …)')}</span>
475
+ <span class="portfolio-flow-arrow">→</span>
476
+ <span class="portfolio-flow-step"><span class="portfolio-flow-num">③</span> ${tr('portfolio.builder.flow3', 'Paste the resulting HTML back here and publish')}</span>
477
+ </div>
415
478
 
416
479
  ${existingConfig?.enabled && html`
417
- <div style="margin-bottom:1.5rem; padding:0.75rem 1rem; background:rgba(80,200,120,0.08); border:1px solid rgba(80,200,120,0.2); border-radius:8px; display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
418
- <span style="color:#50c878;">●</span>
480
+ <div class="portfolio-published-bar">
481
+ <span class="portfolio-published-dot">●</span>
419
482
  <span>${t('portfolio.builder.enabled')}</span>
420
- <a href="/v1/portfolio/${encodeURIComponent(session.owner)}" target="_blank" style="margin-left:auto;">${t('portfolio.builder.viewPublic')}</a>
483
+ <a href=${publicUrl} target="_blank" class="portfolio-published-link">${t('portfolio.builder.viewPublic')}</a>
484
+ <button class="btn btn-ghost btn-sm" disabled=${uploading} onClick=${handleUnpublish}>
485
+ ${tr('portfolio.builder.unpublish', 'Unpublish')}
486
+ </button>
421
487
  </div>
422
488
  `}
423
489
 
@@ -431,7 +497,7 @@ function PortfolioBuilder({ session, navigate }) {
431
497
 
432
498
  ${catalog.images.length > 0 && html`
433
499
  <details class="portfolio-source-group" open>
434
- <summary>${t('portfolio.builder.imagesGroup')} (${catalog.images.length})</summary>
500
+ ${groupSummary(t('portfolio.builder.imagesGroup'), catalog.images.map(i => i.key), selectedImages, setSelectedImages)}
435
501
  ${(() => {
436
502
  const allImgTags = new Set();
437
503
  for (const img of catalog.images) {
@@ -470,7 +536,7 @@ function PortfolioBuilder({ session, navigate }) {
470
536
 
471
537
  ${catalog.apps.length > 0 && html`
472
538
  <details class="portfolio-source-group">
473
- <summary>${t('portfolio.builder.appsGroup')} (${catalog.apps.length})</summary>
539
+ ${groupSummary(t('portfolio.builder.appsGroup'), catalog.apps.map(a => a.filename), selectedApps, setSelectedApps)}
474
540
  <div class="portfolio-source-list">
475
541
  ${catalog.apps.map(app => html`
476
542
  <div class="portfolio-source-item">
@@ -486,7 +552,7 @@ function PortfolioBuilder({ session, navigate }) {
486
552
 
487
553
  ${catalog.boards.length > 0 && html`
488
554
  <details class="portfolio-source-group">
489
- <summary>${t('portfolio.builder.boardsGroup')} (${catalog.boards.length})</summary>
555
+ ${groupSummary(t('portfolio.builder.boardsGroup'), catalog.boards.map(b => b.id), selectedBoards, setSelectedBoards)}
490
556
  <div class="portfolio-source-list">
491
557
  ${catalog.boards.map(board => html`
492
558
  <div class="portfolio-source-item">
@@ -502,7 +568,7 @@ function PortfolioBuilder({ session, navigate }) {
502
568
 
503
569
  ${catalog.cortex.length > 0 && html`
504
570
  <details class="portfolio-source-group">
505
- <summary>${t('portfolio.builder.cortexGroup')} (${catalog.cortex.length})</summary>
571
+ ${groupSummary(t('portfolio.builder.cortexGroup'), catalog.cortex.map(c => c.name), selectedCortex, setSelectedCortex)}
506
572
  <div class="portfolio-source-list">
507
573
  ${catalog.cortex.map(ext => html`
508
574
  <div class="portfolio-source-item">
@@ -518,7 +584,7 @@ function PortfolioBuilder({ session, navigate }) {
518
584
 
519
585
  ${catalog.memories.length > 0 && html`
520
586
  <details class="portfolio-source-group">
521
- <summary>${t('portfolio.builder.memoriesGroup')} (${catalog.memories.length})</summary>
587
+ ${groupSummary(t('portfolio.builder.memoriesGroup'), catalog.memories.map(m => m.key), selectedMemories, setSelectedMemories)}
522
588
  ${(() => {
523
589
  const allMemTags = new Set();
524
590
  for (const mem of catalog.memories) {
@@ -575,6 +641,11 @@ function PortfolioBuilder({ session, navigate }) {
575
641
  </div>
576
642
  `)}
577
643
  </div>
644
+ ${portfolioType === 'custom' && html`
645
+ <textarea class="portfolio-custom-desc" rows="3"
646
+ placeholder=${tr('portfolio.builder.customTypePlaceholder', 'Describe what kind of portfolio you want…')}
647
+ value=${customTypeDesc} onInput=${(e) => setCustomTypeDesc(e.target.value)}></textarea>
648
+ `}
578
649
 
579
650
  <p style="color:var(--text-bright); font-size:0.95rem; margin:1.5rem 0 0.5rem;">${t('portfolio.builder.designStyle')}</p>
580
651
  <div class="portfolio-options">
@@ -601,18 +672,29 @@ function PortfolioBuilder({ session, navigate }) {
601
672
  </div>
602
673
  `)}
603
674
  </div>
675
+ ${authGates.has('custom') && html`
676
+ <textarea class="portfolio-custom-desc" rows="2"
677
+ placeholder=${tr('portfolio.builder.customGatePlaceholder', 'Describe the custom auth-gated sections…')}
678
+ value=${customGateDesc} onInput=${(e) => setCustomGateDesc(e.target.value)}></textarea>
679
+ `}
604
680
  </div>
605
681
 
606
682
  <!-- Step 4: Generate Prompt -->
607
683
  <div class="portfolio-step">
608
684
  <h3><span class="portfolio-step-number">4</span> ${t('portfolio.builder.step4Title')}</h3>
609
685
 
610
- <button class="btn btn-primary" onClick=${handleGenerate} style="margin-bottom:1rem;">
611
- ${t('portfolio.builder.generateBtn')}
612
- </button>
686
+ <div class="portfolio-generate-row">
687
+ <button class="btn btn-primary" disabled=${totalSelected === 0} onClick=${handleGenerate}>
688
+ ${t('portfolio.builder.generateBtn')}
689
+ </button>
690
+ ${totalSelected === 0 && html`
691
+ <span class="portfolio-generate-hint">${tr('portfolio.builder.generateDisabledHint', 'Select at least one item in step 1')}</span>
692
+ `}
693
+ </div>
613
694
 
614
695
  ${generatedPrompt && html`
615
696
  <div class="portfolio-prompt-output">${generatedPrompt}</div>
697
+ <div class="portfolio-prompt-meta">${tr('portfolio.builder.charCount', '{n} characters').replace('{n}', generatedPrompt.length.toLocaleString())}</div>
616
698
  <div class="portfolio-prompt-actions">
617
699
  <${CopyButton} text=${generatedPrompt} className="btn-primary"
618
700
  label=${t('portfolio.builder.copyPrompt')} copiedLabel=${t('portfolio.builder.promptCopied')} />
@@ -637,6 +719,11 @@ function PortfolioBuilder({ session, navigate }) {
637
719
  <div class="portfolio-step">
638
720
  <h3><span class="portfolio-step-number">5</span> ${t('portfolio.builder.step5Title')}</h3>
639
721
 
722
+ <div class="portfolio-publish-target">
723
+ ${tr('portfolio.builder.publishTarget', 'Will be published at:')}
724
+ <a href=${publicUrl} target="_blank" class="portfolio-publish-url">${publicUrl}</a>
725
+ </div>
726
+
640
727
  <div class="portfolio-upload-zone ${dragover ? 'dragover' : ''}"
641
728
  onClick=${() => fileInputRef.current?.click()}
642
729
  onDragOver=${(e) => { e.preventDefault(); setDragover(true); }}
@@ -651,21 +738,31 @@ function PortfolioBuilder({ session, navigate }) {
651
738
 
652
739
  <div style="display:flex; align-items:center; gap:0.75rem; margin:1rem 0; color:var(--text-dim); font-size:0.9rem;">
653
740
  <hr style="flex:1; border:none; border-top:1px solid var(--border-dim);" />
654
- <span>${t('portfolio.builder.orPaste') || 'or paste HTML directly'}</span>
741
+ <span>${tr('portfolio.builder.orPaste', 'or paste HTML directly')}</span>
655
742
  <hr style="flex:1; border:none; border-top:1px solid var(--border-dim);" />
656
743
  </div>
657
744
 
658
745
  <textarea class="portfolio-paste-area"
659
- placeholder=${t('portfolio.builder.pasteHint') || 'Paste your portfolio HTML here from AI chat...'}
746
+ placeholder=${tr('portfolio.builder.pasteHint', 'Paste your portfolio HTML here from AI chat...')}
660
747
  value=${pastedHtml}
661
748
  onInput=${(e) => setPastedHtml(e.target.value)}
662
749
  rows="6"
663
750
  ></textarea>
664
751
  ${pastedHtml.trim() && html`
665
- <button class="btn btn-primary" style="margin-top:0.5rem;"
666
- onClick=${handlePublishPaste} disabled=${uploading}>
667
- ${uploading ? '...' : (t('portfolio.builder.publishPaste') || 'Publish pasted HTML')}
668
- </button>
752
+ <div class="portfolio-paste-actions">
753
+ <button class="btn btn-primary"
754
+ onClick=${handlePublishPaste} disabled=${uploading}>
755
+ ${uploading ? '...' : tr('portfolio.builder.publishPaste', 'Publish pasted HTML')}
756
+ </button>
757
+ <button class="btn btn-ghost" onClick=${() => setShowPreview(p => !p)}>
758
+ ${showPreview ? tr('portfolio.builder.closePreview', 'Close preview') : tr('portfolio.builder.previewBtn', 'Preview')}
759
+ </button>
760
+ </div>
761
+ `}
762
+ ${showPreview && pastedHtml.trim() && html`
763
+ <!-- Same sandbox as the public viewer: opaque origin, no session access. -->
764
+ <iframe class="portfolio-viewer-frame portfolio-preview-frame" srcdoc=${pastedHtml}
765
+ sandbox="allow-scripts"></iframe>
669
766
  `}
670
767
 
671
768
  ${uploadStatus && html`
@@ -682,7 +779,7 @@ function PortfolioBuilder({ session, navigate }) {
682
779
 
683
780
  <div style="margin-top:2rem;">
684
781
  <button class="btn btn-ghost" onClick=${() => navigate('/v1/profile')}>
685
- ${t('portfolio.viewer.backToPortal')}
782
+ ← ${tr('portfolio.builder.backToProfile', 'Profile')}
686
783
  </button>
687
784
  </div>
688
785
  </div>
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @file pricing.js
3
+ * @description Pricing ladder: Try it (0 €) → Own node (49 €/mo) → Own node Pro (99 €/mo)
4
+ * → Done for you (setup + monthly) → Enterprise (contact). No payment integration at
5
+ * this stage — CTAs are mailto links. #packages anchors the Done-for-you section
6
+ * (landing path card C links here). Short sentences, no hype adjectives.
7
+ * @usage routed at /v1/pricing by spa.html
8
+ * @version-history
9
+ * v1.0.0 — 2026-06-10 — Initial pricing page (landing/portal split).
10
+ */
11
+ import { h } from 'preact';
12
+ import { useEffect } from 'preact/hooks';
13
+ import htm from 'htm';
14
+ const html = htm.bind(h);
15
+ import { t } from '/js/i18n.js';
16
+ import { ContactCard } from '/components/ContactCard.js';
17
+
18
+ const tr = (key, fallback) => { const v = t(key); return v && v !== key ? v : fallback; };
19
+
20
+ const CONTACT = 'mailto:jouni.miikki@aimeat.io';
21
+
22
+ export default function Pricing() {
23
+ // Honour the #packages anchor on arrival.
24
+ useEffect(() => {
25
+ if (window.location.hash) {
26
+ setTimeout(() => document.getElementById(window.location.hash.slice(1))?.scrollIntoView({ behavior: 'smooth' }), 200);
27
+ }
28
+ }, []);
29
+
30
+ const tier = (id, name, price, lines, ctaLabel, ctaHref, hot) => html`
31
+ <div class="ld-tier${hot ? ' ld-tier--hot' : ''}" id=${id || null}>
32
+ <h3 class="ld-tier-name">${name}</h3>
33
+ <div class="ld-tier-price">${price}</div>
34
+ <ul class="ld-tier-lines">${lines.map(l => html`<li key=${l}>${l}</li>`)}</ul>
35
+ <a class="ld-path-cta" href=${ctaHref}>${ctaLabel}</a>
36
+ </div>
37
+ `;
38
+
39
+ return html`
40
+ <div class="ld ld-pricing">
41
+ <h1 class="ld-h1">${tr('pricing.title', 'Pricing')}</h1>
42
+ <p class="ld-hero-sub">${tr('pricing.sub', 'Start free. Move to your own node when the data should be yours.')}</p>
43
+
44
+ <div class="ld-tiers">
45
+ ${tier('try-it', tr('pricing.tryName', 'Try it'), '0 €', [
46
+ tr('pricing.try1', 'On the genesis node.'),
47
+ tr('pricing.try2', 'Morsels, public apps, the prompt loop.'),
48
+ ], tr('pricing.tryCta', 'Start free →'), '/v1/portal')}
49
+ ${tier('own-node', tr('pricing.ownName', 'Own node'), '49 €/' + tr('pricing.mo', 'mo'), [
50
+ tr('pricing.own1', 'Your own instance, your data, BYOK.'),
51
+ tr('pricing.own2', 'No backups, community support.'),
52
+ ], tr('pricing.contactCta', 'Contact us →'), CONTACT)}
53
+ ${tier('own-node-pro', tr('pricing.proName', 'Own node Pro'), '99 €/' + tr('pricing.mo', 'mo'), [
54
+ tr('pricing.pro1', 'Backups and a bigger instance.'),
55
+ tr('pricing.pro2', 'Ready-made agent packages (CrewAI crew installed).'),
56
+ tr('pricing.pro3', 'Email support.'),
57
+ ], tr('pricing.contactCta', 'Contact us →'), CONTACT, true)}
58
+ </div>
59
+
60
+ <div class="ld-tiers" id="packages">
61
+ ${tier(null, tr('pricing.dfyName', 'Done for you'), '990 € + 49 €/' + tr('pricing.mo', 'mo') + ' · 2500 € + 250 €/' + tr('pricing.mo', 'mo'), [
62
+ tr('pricing.dfy1', 'Installation, your agents built, onboarding.'),
63
+ tr('pricing.dfy2', 'For small businesses.'),
64
+ tr('pricing.dfyExample', 'Example: a barbershop. Every morning at 7, your phone has the day’s bookings, yesterday’s feedback and a social post ready to approve.'),
65
+ ], tr('pricing.dfyCta', 'See what it looks like →'), '/v1/business')}
66
+ ${tier(null, tr('pricing.entName', 'Enterprise'), tr('pricing.entPrice', 'contact'), [
67
+ tr('pricing.ent1', 'Governance, data inside your own walls.'),
68
+ tr('pricing.ent2', 'Health and finance sectors.'),
69
+ ], tr('pricing.contactCta', 'Contact us →'), CONTACT)}
70
+ </div>
71
+
72
+ <${ContactCard} />
73
+ </div>
74
+ `;
75
+ }