Skip to content

Instantly share code, notes, and snippets.

@EDDYMENS
Created August 1, 2026 17:24
Show Gist options
  • Select an option

  • Save EDDYMENS/7002d55722211c7ebe80a023e9a5fc74 to your computer and use it in GitHub Desktop.

Select an option

Save EDDYMENS/7002d55722211c7ebe80a023e9a5fc74 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tenseri — Onboarding</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.4.21/vue.global.prod.min.js"></script>
<style>
/* Same token subset as your main stylesheet — delete this :root
block once this page pulls in the real one, keep the rest. */
:root{
--bg:#f7f6f3;--bg-card:#fff;--bg-subtle:#f2f0ec;--bg-hover:#ece9e3;
--border:#e2ded6;--border-mid:#ccc8c0;--border-strong:#b8b3aa;
--text-primary:#1a1916;--text-secondary:#6b6760;--text-muted:#9e9b96;
--accent:#1a1916;--accent-hover:#2e2c28;--accent-soft:#f0ede8;
--green:#1a6b3c;--red:#b91c1c;
--font:'DM Sans',-apple-system,BlinkMacSystemFont,sans-serif;
--font-mono:'IBM Plex Mono','Courier New',monospace;
--transition:.15s ease;
}
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden}
body{font-family:var(--font);background:var(--bg);color:var(--text-primary)}
#app{height:100%;display:flex;flex-direction:column}
.ob-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:32px 20px;text-align:center;position:relative;overflow-y:auto}
.ob-progress{position:absolute;top:0;left:0;right:0;display:flex;gap:4px;padding:16px 24px 0}
.ob-progress__dot{height:3px;flex:1;background:var(--border);transition:background var(--transition)}
.ob-progress__dot.is-done{background:var(--accent)}
.ob-loader{display:flex;flex-direction:column;align-items:center;gap:18px}
.ob-loader__ring{width:34px;height:34px;border:2.5px solid var(--border-mid);border-top-color:var(--accent);border-radius:50%;animation:ob-spin .8s linear infinite}
@keyframes ob-spin{to{transform:rotate(360deg)}}
.ob-loader__label{font-family:var(--font-mono);font-size:12.5px;color:var(--text-secondary);letter-spacing:.02em;min-height:18px}
.ob-checklist{display:flex;flex-direction:column;gap:10px;margin-top:6px;text-align:left;width:280px}
.ob-checklist__item{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--text-muted);transition:color var(--transition)}
.ob-checklist__item.is-active{color:var(--text-primary);font-weight:500}
.ob-checklist__item.is-complete{color:var(--text-secondary)}
.ob-checklist__mark{width:16px;height:16px;flex-shrink:0;border:1.5px solid var(--border-mid);display:flex;align-items:center;justify-content:center;font-size:10px;font-family:var(--font-mono);transition:all var(--transition)}
.ob-checklist__item.is-active .ob-checklist__mark{border-color:var(--accent)}
.ob-checklist__item.is-complete .ob-checklist__mark{background:var(--accent);border-color:var(--accent);color:#fff}
.ob-fade-swap-enter-active,.ob-fade-swap-leave-active{transition:opacity .18s ease}
.ob-fade-swap-enter-from,.ob-fade-swap-leave-to{opacity:0}
.ob-lang-grid{display:grid;grid-template-columns:repeat(2,minmax(0,220px));gap:10px;margin-top:28px}
@media(max-width:520px){.ob-lang-grid{grid-template-columns:1fr}}
.ob-lang-btn{padding:14px 18px;border:1px solid var(--border-mid);background:var(--bg-card);cursor:pointer;font-family:var(--font);font-size:14px;color:var(--text-primary);text-align:left;transition:border-color var(--transition),background var(--transition)}
.ob-lang-btn:hover{background:var(--bg-subtle);border-color:var(--accent)}
.ob-lang-btn.is-selected{border-color:var(--accent);background:var(--accent-soft);font-weight:600}
.ob-lang-btn small{display:block;color:var(--text-muted);font-size:11px;margin-top:2px}
.ob-usecase-grid{display:grid;grid-template-columns:repeat(3,minmax(0,220px));gap:12px;margin-top:28px;max-width:760px}
@media(max-width:820px){.ob-usecase-grid{grid-template-columns:repeat(2,minmax(0,220px))}}
@media(max-width:520px){.ob-usecase-grid{grid-template-columns:1fr}}
.ob-usecase-card{border:1px solid var(--border-mid);background:var(--bg-card);padding:18px 16px;cursor:pointer;text-align:left;transition:border-color var(--transition),background var(--transition),transform var(--transition)}
.ob-usecase-card:hover{border-color:var(--accent);transform:translateY(-1px)}
.ob-usecase-card.is-selected{border-color:var(--accent);background:var(--accent-soft)}
.ob-usecase-card__glyph{font-family:var(--font-mono);font-size:11px;color:var(--text-muted);border:1px solid var(--border);width:26px;height:26px;display:flex;align-items:center;justify-content:center;margin-bottom:10px}
.ob-usecase-card.is-selected .ob-usecase-card__glyph{border-color:var(--accent);color:var(--accent)}
.ob-usecase-card__title{font-weight:600;font-size:13.5px;margin-bottom:4px}
.ob-usecase-card__desc{font-size:12px;color:var(--text-secondary);line-height:1.5}
.ob-check{width:44px;height:44px;border:2px solid var(--green);border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--green);font-size:20px;margin-bottom:18px}
.ob-video-list{width:100%;max-width:460px;margin-top:24px;text-align:left;display:flex;flex-direction:column;gap:8px}
.ob-video-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);background:var(--bg-card);text-decoration:none;color:var(--text-primary);font-size:13px;transition:background var(--transition),border-color var(--transition)}
.ob-video-item:hover{background:var(--bg-subtle);border-color:var(--border-strong)}
.ob-video-item__icon{font-family:var(--font-mono);font-size:10px;color:var(--red);flex-shrink:0;border:1px solid currentColor;width:22px;height:16px;display:flex;align-items:center;justify-content:center}
.ob-video-item__title{flex:1;font-weight:500}
.ob-video-item__len{font-family:var(--font-mono);font-size:11px;color:var(--text-muted)}
.ob-footer{border-top:1px solid var(--border);padding:16px 24px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
.ob-btn{padding:9px 20px;border:1px solid var(--border-mid);background:var(--bg-card);cursor:pointer;font-family:var(--font);font-size:13px;font-weight:500;color:var(--text-primary);transition:background var(--transition),border-color var(--transition)}
.ob-btn:hover{background:var(--bg-hover)}
.ob-btn:disabled{opacity:.4;cursor:not-allowed}
.ob-btn--primary{background:var(--accent);border-color:var(--accent);color:#fff}
.ob-btn--primary:hover{background:var(--accent-hover)}
.ob-title{font-size:22px;font-weight:700;letter-spacing:-.4px;margin-bottom:6px}
.ob-subtitle{font-size:13.5px;color:var(--text-secondary);max-width:420px;line-height:1.5}
.ob-error{margin-top:14px;font-size:12.5px;color:var(--red)}
</style>
</head>
<body>
<div id="app">
<div class="ob-progress">
<div v-for="(s, i) in stageOrder" :key="s" class="ob-progress__dot" :class="{ 'is-done': stageOrder.indexOf(stage) >= i }"></div>
</div>
<div class="ob-stage">
<!-- 1. Loader: pulling the onboarding config -->
<template v-if="stage === 'fetching'">
<div class="ob-loader">
<div class="ob-loader__ring"></div>
<div class="ob-loader__label">{{ fetchLabel }}</div>
</div>
</template>
<!-- 2. Language: picker is visible immediately; the heading text
cycles through each language's own translation so someone who
doesn't yet read the selected language still understands this
is the language picker. -->
<template v-else-if="stage === 'language'">
<transition name="ob-fade-swap" mode="out-in">
<div :key="flash.title">
<div class="ob-title">{{ flash.title }}</div>
<div class="ob-subtitle">{{ flash.subtitle }}</div>
</div>
</transition>
<div class="ob-lang-grid">
<button v-for="lang in languages" :key="lang.code" class="ob-lang-btn"
:class="{ 'is-selected': language === lang.code }"
@click="selectLanguage(lang.code)">
{{ lang.native }}
<small>{{ lang.english }}</small>
</button>
</div>
</template>
<!-- 3. Use case -->
<template v-else-if="stage === 'usecase'">
<div class="ob-title">{{ t('usecaseTitle') }}</div>
<div class="ob-subtitle">{{ t('usecaseSubtitle') }}</div>
<div class="ob-usecase-grid">
<div v-for="uc in useCases" :key="uc.key" class="ob-usecase-card"
:class="{ 'is-selected': useCase === uc.key }" @click="useCase = uc.key">
<div class="ob-usecase-card__glyph">{{ uc.glyph }}</div>
<div class="ob-usecase-card__title">{{ uc.title }}</div>
<div class="ob-usecase-card__desc">{{ uc.desc }}</div>
</div>
</div>
</template>
<!-- 4. Setup: real API calls happen here -->
<template v-else-if="stage === 'setup'">
<div class="ob-loader">
<div class="ob-loader__ring"></div>
<div class="ob-checklist">
<div v-for="(step, i) in setupSteps" :key="step.label" class="ob-checklist__item"
:class="{ 'is-active': setupIndex === i, 'is-complete': setupIndex > i }">
<span class="ob-checklist__mark">{{ setupIndex > i ? '✓' : '' }}</span>
<span>{{ step.label }}</span>
</div>
</div>
<div v-if="setupError" class="ob-error">{{ setupError }} — <a href="#" @click.prevent="startSetup">retry</a></div>
</div>
</template>
<!-- 5. Done -->
<template v-else-if="stage === 'done'">
<div class="ob-check"></div>
<div class="ob-title">You're all set</div>
<div class="ob-subtitle">Your workspace is configured for {{ useCaseLabel(useCase) }}. Here's a short playlist to get you moving.</div>
<div class="ob-video-list">
<a v-for="v in videos" :key="v.url" :href="v.url" target="_blank" rel="noopener" class="ob-video-item">
<span class="ob-video-item__icon"></span>
<span class="ob-video-item__title">{{ v.title }}</span>
<span class="ob-video-item__len">{{ v.length }}</span>
</a>
</div>
</template>
</div>
<div class="ob-footer">
<button class="ob-btn" v-if="stage === 'usecase'" @click="stage = 'language'">Back</button>
<span v-else></span>
<button v-if="stage === 'language'" class="ob-btn ob-btn--primary" :disabled="!language" @click="stage = 'usecase'">Continue</button>
<button v-else-if="stage === 'usecase'" class="ob-btn ob-btn--primary" :disabled="!useCase" @click="startSetup">Continue</button>
<button v-else-if="stage === 'done'" class="ob-btn ob-btn--primary" @click="complete">Start using Tenseri</button>
</div>
</div>
<script>
const { createApp } = Vue;
/* Point this at your real .NET minimal API base. Read from a query
param / data attribute so the same built page works across envs,
e.g. onboarding.html?api=https://app.tenseri.io/api */
const API_BASE = new URLSearchParams(location.search).get('api') || '/api';
const ALL_TABS = ['businesses','accounts','apps','transactions','trial-balance','balance-sheet','income-statement','cash-flow','notes','admin'];
const USE_CASE_TAB_CONFIG = {
accountant: ALL_TABS,
personal_finance: ['accounts','transactions','balance-sheet','income-statement','notes','admin'],
student: ['accounts','transactions','trial-balance','balance-sheet','income-statement','cash-flow','notes'],
service_business: ['businesses','accounts','transactions','income-statement','balance-sheet','trial-balance','cash-flow','apps','notes','admin'],
product_business: ['businesses','accounts','transactions','income-statement','balance-sheet','trial-balance','cash-flow','apps','notes','admin'],
portfolio: ['accounts','transactions','balance-sheet','apps','notes','admin'],
};
const USE_CASES = [
{ key:'accountant', glyph:'A', title:'Accountant or bookkeeper', desc:'I manage the books for clients or an organization and need the full toolset.' },
{ key:'personal_finance', glyph:'$', title:'Personal finance', desc:"I'm tracking my own money, budgets, and spending." },
{ key:'student', glyph:'S', title:'Student', desc:"I'm learning accounting or bookkeeping and want to practice." },
{ key:'service_business', glyph:'B', title:'Service business', desc:'I run a business that sells services — consulting, agencies, freelancing.' },
{ key:'product_business', glyph:'P', title:'Product business', desc:'I run a business that makes or sells physical goods.' },
{ key:'portfolio', glyph:'%', title:'Investment portfolio', desc:"I'm tracking my own stocks, options, or other investments." },
];
const LANGUAGES = [
{ code:'en', native:'English', english:'English' },
{ code:'fr', native:'Français', english:'French' },
{ code:'de', native:'Deutsch', english:'German' },
{ code:'es', native:'Español', english:'Spanish' },
{ code:'pt', native:'Português', english:'Portuguese' },
{ code:'lb', native:'Lëtzebuergesch', english:'Luxembourgish' },
];
// NOTE: machine-translated for this preview — have a native
// speaker (or your translation pipeline) check these before ship.
const STRINGS = {
en: { greeting:'Hello', title:'Choose your language', subtitle:'You can change this later in Settings.', usecaseTitle:'What brings you to Tenseri?', usecaseSubtitle:"This sets up your workspace with the tabs and tools you'll actually use — you can adjust it anytime in Settings." },
fr: { greeting:'Bonjour', title:'Choisissez votre langue', subtitle:'Vous pourrez la modifier plus tard dans les paramètres.', usecaseTitle:'Qu\u2019est-ce qui vous amène à Tenseri\u00a0?', usecaseSubtitle:'Cela configure votre espace de travail avec les onglets et outils dont vous aurez vraiment besoin — modifiable à tout moment dans les paramètres.' },
de: { greeting:'Hallo', title:'Wählen Sie Ihre Sprache', subtitle:'Sie können dies später in den Einstellungen ändern.', usecaseTitle:'Was führt Sie zu Tenseri?', usecaseSubtitle:'Damit wird Ihr Arbeitsbereich mit den Tabs und Tools eingerichtet, die Sie wirklich brauchen — jederzeit in den Einstellungen änderbar.' },
es: { greeting:'Hola', title:'Elige tu idioma', subtitle:'Puedes cambiarlo más tarde en Configuración.', usecaseTitle:'¿Qué te trae a Tenseri?', usecaseSubtitle:'Esto configura tu espacio de trabajo con las pestañas y herramientas que realmente usarás — puedes ajustarlo cuando quieras en Configuración.' },
pt: { greeting:'Olá', title:'Escolha o seu idioma', subtitle:'Pode alterar isto mais tarde nas Definições.', usecaseTitle:'O que o traz à Tenseri?', usecaseSubtitle:'Isto configura o seu espaço de trabalho com os separadores e ferramentas que vai realmente usar — pode ajustar a qualquer momento nas Definições.' },
lb: { greeting:'Moien', title:'Wielt Är Sprooch', subtitle:'Dir kënnt dat méi spéit an den Astellungen änneren.', usecaseTitle:'Wat bréngt Iech op Tenseri?', usecaseSubtitle:'Dëst richt Ären Aarbechtsberäich mat den Tabs an Tools an, déi Dir wierklech braucht — jidderzäit änderbar an den Astellungen.' },
};
createApp({
data(){
return {
stageOrder: ['fetching','language','usecase','setup','done'],
stage: 'fetching',
fetchLabel: 'Preparing your setup…',
languages: LANGUAGES,
language: null,
langFlashing: true,
flash: STRINGS.en,
useCases: USE_CASES,
useCase: null,
setupIndex: 0,
setupError: null,
setupSteps: [
{ label: 'Saving your preferences' },
{ label: 'Configuring your workspace tabs' },
{ label: 'Finishing up' },
],
videos: [],
tabConfig: null,
};
},
mounted(){
this.beginOnboarding();
},
methods: {
t(key){ return (STRINGS[this.language] || STRINGS.en)[key]; },
useCaseLabel(key){
const uc = this.useCases.find(u => u.key === key);
return uc ? uc.title.toLowerCase() : 'your workspace';
},
async beginOnboarding(){
this.stage = 'fetching';
try {
// Real call: fetch the onboarding "code"/content bundle.
const res = await fetch(`${API_BASE}/onboarding/config`);
if (!res.ok) throw new Error('bad response');
await res.json(); // onboarding steps/content, if you version them server-side
} catch (e) {
// Fine to no-op here — this page ships its own copy of the
// flow already; the call above is only useful if you want
// to drive copy/steps from the server instead.
}
this.stage = 'language';
this.runLanguageFlash();
},
async runLanguageFlash(){
this.langFlashing = true;
for (const lang of LANGUAGES) {
this.flash = STRINGS[lang.code] || STRINGS.en;
await this.wait(850);
}
this.language = this.language || 'en';
this.flash = STRINGS[this.language];
this.langFlashing = false;
},
async startSetup(){
this.stage = 'setup';
this.setupIndex = 0;
this.setupError = null;
try {
await this.wait(300);
await fetch(`${API_BASE}/users/me/locale`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: this.language }),
});
this.setupIndex = 1;
await this.wait(300);
this.tabConfig = USE_CASE_TAB_CONFIG[this.useCase] || ALL_TABS;
await fetch(`${API_BASE}/settings/tabs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ useCase: this.useCase, visibleTabs: this.tabConfig }),
});
this.setupIndex = 2;
await this.wait(300);
await fetch(`${API_BASE}/users/me/onboarding-complete`, { method: 'POST' });
this.setupIndex = 3;
await this.wait(250);
this.loadVideosFor(this.useCase);
this.stage = 'done';
} catch (e) {
this.setupError = "Couldn't save your setup — check your connection.";
}
},
loadVideosFor(useCaseKey){
// TODO: replace with your real tutorial video URLs per use case.
this.videos = [
{ title:'Getting started with Tenseri', length:'4:12', url:'https://www.youtube.com/results?search_query=tenseri+getting+started' },
{ title:'Setting up your chart of accounts', length:'6:40', url:'https://www.youtube.com/results?search_query=tenseri+chart+of+accounts' },
{ title:`Tenseri for ${this.useCaseLabel(useCaseKey)}`, length:'5:05', url:'https://www.youtube.com/results?search_query=tenseri+tutorial' },
];
},
// Hand control back to the parent window instead of touching
// localStorage directly — the iframe may be on a different
// origin than the host app, so postMessage is the reliable path.
complete(){
const payload = {
type: 'tenseri:onboarding-complete',
language: this.language,
useCase: this.useCase,
tabConfig: this.tabConfig,
};
if (window.parent && window.parent !== window) {
// Swap '*' for your app's real origin once this is live.
window.parent.postMessage(payload, '*');
}
},
wait(ms){ return new Promise(r => setTimeout(r, ms)); },
},
}).mount('#app');
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment