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.
- package/dist/locales/en.json +536 -21
- package/dist/locales/fi.json +548 -33
- package/dist/public/components/ContactCard.js +28 -0
- package/dist/public/css/views/admin.css +12 -0
- package/dist/public/css/views/agents-detail.css +62 -10
- package/dist/public/css/views/landing.css +87 -0
- package/dist/public/css/views/offers.css +96 -0
- package/dist/public/css/views/portfolio.css +18 -0
- package/dist/public/css/views/profile.css +153 -8
- package/dist/public/css/views/start.css +96 -0
- package/dist/public/img/business-hero.png +0 -0
- package/dist/public/img/toimitus.png +0 -0
- package/dist/public/js/components/tag-cloud.js +46 -31
- package/dist/public/js/components/tag-editor.js +2 -2
- package/dist/public/js/recents.js +40 -0
- package/dist/public/js/services/admin.js +6 -0
- package/dist/public/js/services/agents.js +34 -0
- package/dist/public/js/services/offers.js +152 -0
- package/dist/public/js/services/packages.js +1 -0
- package/dist/public/spa.html +32 -2
- package/dist/public/views/admin/subdomains-tab.js +182 -0
- package/dist/public/views/admin.js +2 -0
- package/dist/public/views/business.js +119 -0
- package/dist/public/views/how-it-works.js +159 -0
- package/dist/public/views/landing.js +235 -0
- package/dist/public/views/portfolio.js +120 -23
- package/dist/public/views/pricing.js +75 -0
- package/dist/public/views/profile/access-tab.js +124 -51
- package/dist/public/views/profile/agents/agent-card.js +68 -37
- package/dist/public/views/profile/agents/shared-board.js +56 -22
- package/dist/public/views/profile/agents/tab-activity.js +24 -12
- package/dist/public/views/profile/agents/tab-agent-config.js +31 -1
- package/dist/public/views/profile/agents/tab-data-access.js +9 -13
- package/dist/public/views/profile/agents/tab-integration.js +27 -1
- package/dist/public/views/profile/agents/tab-messages.js +6 -11
- package/dist/public/views/profile/agents/tab-quality.js +45 -5
- package/dist/public/views/profile/agents-tab.js +41 -6
- package/dist/public/views/profile/apps-tab.js +7 -1
- package/dist/public/views/profile/knowledge-tab.js +4 -1
- package/dist/public/views/profile/landing-page.js +427 -123
- package/dist/public/views/profile/memory-tab.js +409 -211
- package/dist/public/views/profile/nodes-tab.js +18 -3
- package/dist/public/views/profile/offers-tab.js +310 -0
- package/dist/public/views/profile/organisms-tab.js +289 -28
- package/dist/public/views/profile/packages-tab.js +152 -16
- package/dist/public/views/profile/portfolio-tab.js +98 -31
- package/dist/public/views/profile.js +2 -0
- package/dist/public/views/start-flows.js +281 -0
- package/dist/public/views/start.js +258 -0
- package/dist/src/generated/api-types.d.ts +856 -17
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/generated/prisma-postgres/edge.js +15 -3
- package/dist/src/generated/prisma-postgres/index-browser.js +12 -0
- package/dist/src/generated/prisma-postgres/index.d.ts +1476 -160
- package/dist/src/generated/prisma-postgres/index.js +15 -3
- package/dist/src/generated/prisma-postgres/package.json +1 -1
- package/dist/src/generated/prisma-postgres/schema.prisma +11 -0
- package/dist/src/generated/prisma-postgres/wasm.js +15 -3
- package/dist/src/middleware/subdomain.d.ts +28 -0
- package/dist/src/middleware/subdomain.d.ts.map +1 -0
- package/dist/src/middleware/subdomain.js +32 -0
- package/dist/src/middleware/subdomain.js.map +1 -0
- package/dist/src/models/offer-schemas.d.ts +229 -0
- package/dist/src/models/offer-schemas.d.ts.map +1 -0
- package/dist/src/models/offer-schemas.js +100 -0
- package/dist/src/models/offer-schemas.js.map +1 -0
- package/dist/src/routes/agent-tasks.d.ts.map +1 -1
- package/dist/src/routes/agent-tasks.js +33 -0
- package/dist/src/routes/agent-tasks.js.map +1 -1
- package/dist/src/routes/agents.d.ts.map +1 -1
- package/dist/src/routes/agents.js +146 -1
- package/dist/src/routes/agents.js.map +1 -1
- package/dist/src/routes/apps-backup.d.ts +20 -0
- package/dist/src/routes/apps-backup.d.ts.map +1 -0
- package/dist/src/routes/apps-backup.js +165 -0
- package/dist/src/routes/apps-backup.js.map +1 -0
- package/dist/src/routes/ghii.d.ts.map +1 -1
- package/dist/src/routes/ghii.js +4 -0
- package/dist/src/routes/ghii.js.map +1 -1
- package/dist/src/routes/instances.d.ts +2 -1
- package/dist/src/routes/instances.d.ts.map +1 -1
- package/dist/src/routes/instances.js +59 -1
- package/dist/src/routes/instances.js.map +1 -1
- package/dist/src/routes/packages.d.ts.map +1 -1
- package/dist/src/routes/packages.js +32 -0
- package/dist/src/routes/packages.js.map +1 -1
- package/dist/src/routes/permissions.d.ts +12 -0
- package/dist/src/routes/permissions.d.ts.map +1 -1
- package/dist/src/routes/permissions.js +35 -3
- package/dist/src/routes/permissions.js.map +1 -1
- package/dist/src/routes/portal.d.ts +3 -0
- package/dist/src/routes/portal.d.ts.map +1 -1
- package/dist/src/routes/portal.js +12 -0
- package/dist/src/routes/portal.js.map +1 -1
- package/dist/src/routes/public-stats.d.ts +15 -0
- package/dist/src/routes/public-stats.d.ts.map +1 -0
- package/dist/src/routes/public-stats.js +90 -0
- package/dist/src/routes/public-stats.js.map +1 -0
- package/dist/src/routes/sse.d.ts +7 -2
- package/dist/src/routes/sse.d.ts.map +1 -1
- package/dist/src/routes/sse.js +35 -5
- package/dist/src/routes/sse.js.map +1 -1
- package/dist/src/routes/subdomains.d.ts +33 -0
- package/dist/src/routes/subdomains.d.ts.map +1 -0
- package/dist/src/routes/subdomains.js +208 -0
- package/dist/src/routes/subdomains.js.map +1 -0
- package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.js +12 -1
- package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +4 -0
- package/dist/src/server.js.map +1 -1
- package/dist/src/services/apps-backup-export.d.ts +22 -0
- package/dist/src/services/apps-backup-export.d.ts.map +1 -0
- package/dist/src/services/apps-backup-export.js +136 -0
- package/dist/src/services/apps-backup-export.js.map +1 -0
- package/dist/src/services/apps-backup-import.d.ts +120 -0
- package/dist/src/services/apps-backup-import.d.ts.map +1 -0
- package/dist/src/services/apps-backup-import.js +259 -0
- package/dist/src/services/apps-backup-import.js.map +1 -0
- package/dist/src/services/component-registrar.d.ts +17 -0
- package/dist/src/services/component-registrar.d.ts.map +1 -1
- package/dist/src/services/component-registrar.js +63 -5
- package/dist/src/services/component-registrar.js.map +1 -1
- package/dist/src/services/handbooks/appdev.d.ts +1 -1
- package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
- package/dist/src/services/handbooks/appdev.js +5 -2
- package/dist/src/services/handbooks/appdev.js.map +1 -1
- package/dist/src/storage/interface.d.ts +11 -1
- package/dist/src/storage/interface.d.ts.map +1 -1
- package/dist/src/storage/interface.js.map +1 -1
- package/dist/src/storage/providers/mongodb/index.d.ts +10 -1
- package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
- package/dist/src/storage/providers/mongodb/index.js +76 -0
- package/dist/src/storage/providers/mongodb/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/index.d.ts +8 -1
- package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/index.js +61 -0
- package/dist/src/storage/providers/sqlite/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.js +11 -0
- package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
- package/dist/src/storage/repositories/index.d.ts +1 -0
- package/dist/src/storage/repositories/index.d.ts.map +1 -1
- package/dist/src/storage/repositories/package.repository.d.ts +2 -0
- package/dist/src/storage/repositories/package.repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/subdomain-site.repository.d.ts +19 -0
- package/dist/src/storage/repositories/subdomain-site.repository.d.ts.map +1 -0
- package/dist/src/storage/repositories/subdomain-site.repository.js +2 -0
- package/dist/src/storage/repositories/subdomain-site.repository.js.map +1 -0
- package/dist/static/app-catalog.html +1139 -28
- package/package.json +1 -1
- package/prisma/schema.postgres.prisma +11 -0
- 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:
|
|
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
|
|
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
|
|
418
|
-
<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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
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>${
|
|
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=${
|
|
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
|
-
<
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
${
|
|
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
|
+
}
|