Created
August 1, 2026 17:24
-
-
Save EDDYMENS/7002d55722211c7ebe80a023e9a5fc74 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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