falak-app-duo 1.1.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/falak-app-duo +14 -1
- package/package.json +1 -1
- package/templates/default/.env.example +8 -1
- package/templates/default/src/i18n/locales/ar.json +7 -1
- package/templates/default/src/i18n/locales/de.json +7 -1
- package/templates/default/src/i18n/locales/en.json +7 -1
- package/templates/default/src/i18n/locales/es.json +7 -1
- package/templates/default/src/i18n/locales/fr.json +7 -1
- package/templates/default/src/i18n/locales/it.json +7 -1
- package/templates/default/src/i18n/locales/ru.json +7 -1
- package/templates/default/src/services/dataClient.js +22 -0
- package/templates/default/src/services/demoApi.js +377 -0
- package/templates/default/src/stores/auth.js +17 -17
- package/templates/default/src/views/Home.vue +52 -2
- package/templates/default/src/views/Login.vue +26 -1
package/bin/falak-app-duo
CHANGED
|
@@ -540,6 +540,11 @@ async function main() {
|
|
|
540
540
|
: "1 (normal demo user)"
|
|
541
541
|
: "none";
|
|
542
542
|
|
|
543
|
+
// Data mode: demo apps are wired fully to the bundled demo data (auth +
|
|
544
|
+
// profile/settings + entity lists) and need no backend; everything else
|
|
545
|
+
// goes straight to the Laravel API.
|
|
546
|
+
const dataMode = demoAccounts || (demoEntities && demoEntities.length) ? "demo" : "live";
|
|
547
|
+
|
|
543
548
|
console.log(
|
|
544
549
|
chalk.gray(` Languages wired: ${locales.map((l) => LOCALE_LABELS[l]).join(", ")}${hasRtl ? " (RTL default)" : ""}`)
|
|
545
550
|
);
|
|
@@ -548,11 +553,13 @@ async function main() {
|
|
|
548
553
|
` Roles: ${roles && roles.length ? roles.join(", ") : "none"} | Demo accounts: ${demoAccountsSummary} | Demo data: ${demoEntities && demoEntities.length ? demoEntities.join(", ") : "none"}`
|
|
549
554
|
)
|
|
550
555
|
);
|
|
556
|
+
console.log(chalk.gray(` Data mode: ${dataMode}${dataMode === "demo" ? " (no backend needed)" : " (Laravel API)"}`));
|
|
551
557
|
|
|
552
558
|
// Generate a real .env from .env.example with a fresh encryption key
|
|
553
559
|
const envExamplePath = path.join(targetDir, ".env.example");
|
|
554
560
|
let envContent = await fs.readFile(envExamplePath, "utf-8");
|
|
555
561
|
envContent = envContent
|
|
562
|
+
.replace("__DATA_MODE__", dataMode)
|
|
556
563
|
.replace("__API_BASE_URL__", answers.apiBaseUrl || "http://localhost:8000/api")
|
|
557
564
|
.replace("__AES_KEY__", randomHex(32)) // 256-bit key, hex-encoded
|
|
558
565
|
.replace("__AES_IV__", randomHex(16)); // 128-bit IV, hex-encoded
|
|
@@ -561,9 +568,15 @@ async function main() {
|
|
|
561
568
|
console.log(chalk.green(`\n Scaffolded ./${projectName} successfully.\n`));
|
|
562
569
|
console.log(
|
|
563
570
|
chalk.gray(
|
|
564
|
-
" Fill in VITE_API_KEY, VITE_ABLY_PUBLIC_KEY, Brevo backend routes, and review .env before shipping
|
|
571
|
+
" Fill in VITE_API_KEY, VITE_ABLY_PUBLIC_KEY, Brevo backend routes, and review .env before shipping."
|
|
565
572
|
)
|
|
566
573
|
);
|
|
574
|
+
if (dataMode === "demo") {
|
|
575
|
+
console.log(chalk.gray(" Data mode: demo — the app runs on bundled demo data (no API keys needed)."));
|
|
576
|
+
console.log(chalk.gray(" Flip VITE_DATA_MODE=live in .env to wire the app to your Laravel backend.\n"));
|
|
577
|
+
} else {
|
|
578
|
+
console.log(chalk.gray("\n Data mode: live — the app calls your Laravel backend.\n"));
|
|
579
|
+
}
|
|
567
580
|
|
|
568
581
|
const runChoice = opts.yes || hasFeatureFlags
|
|
569
582
|
? "install"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "falak-app-duo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Vue 3 boilerplate scaffolder — Ably (realtime), Brevo, AES-256, Arabic/RTL, and a Laravel API client (API key + user token auth). No Firebase, no Groq/Paymob on the frontend (Groq is backend-only; Paymob dropped).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
# --- Data source ---
|
|
2
|
+
# demo: the app runs entirely on bundled demo data (auth, profile, settings and
|
|
3
|
+
# entity lists) with no backend required.
|
|
4
|
+
# live: every request goes to the Laravel API below.
|
|
5
|
+
# The scaffolder sets this to "demo" when demo accounts/demo data were selected.
|
|
6
|
+
VITE_DATA_MODE=__DATA_MODE__
|
|
7
|
+
|
|
1
8
|
# --- Laravel API (Falak Bridge or your own backend) ---
|
|
2
|
-
# Base URL of your Laravel API
|
|
9
|
+
# Base URL of your Laravel API (not used in demo mode)
|
|
3
10
|
VITE_API_BASE_URL=__API_BASE_URL__
|
|
4
11
|
# Static API key issued to this client app (sent as X-API-Key on every request)
|
|
5
12
|
VITE_API_KEY=replace-with-your-app-api-key
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "جاهز ومربوط.",
|
|
74
74
|
"tagline": "محادثة فورية ورسائل تلقائية ومصادقة AES-256 ومساعد ذكي — كل ذلك في قالب Vue 3 + Laravel واحد.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "وضع العرض مفعّل — جرّب حساب العرض هذا:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "أنشئ حسابك",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "المساعد الذكي",
|
|
161
162
|
"desc": "إعدادات مساعد ذكي مدعوم من Groq جاهزة للتخصيص."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "بيانات تجريبية",
|
|
167
|
+
"subtitle": "تُعرض من src/data/demo/* — لا حاجة لأي خادم. اضبط VITE_DATA_MODE=live للاتصال بالواجهة البرمجية.",
|
|
168
|
+
"entities": "الكيانات"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "schon verbunden.",
|
|
74
74
|
"tagline": "Echtzeit-Chat, transaktionale E-Mails, AES-256 sichere Anmeldung und ein KI-Agent — gebündelt in einem Vue-3- und Laravel-Starter.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "Demo-Modus ist aktiv — versuche dieses Demo-Konto:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Konto erstellen",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "KI-Agent",
|
|
161
162
|
"desc": "Einstellungen für den Groq-KI-Agenten sind bereit zur Konfiguration."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Demodaten",
|
|
167
|
+
"subtitle": "Wird aus src/data/demo/* gerendert — kein Backend nötig. Setze VITE_DATA_MODE=live, um stattdessen die API zu nutzen.",
|
|
168
|
+
"entities": "Entitäten"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "already wired.",
|
|
74
74
|
"tagline": "Realtime chat, transactional email, AES-256 secure auth and an AI agent — bundled in one Vue 3 + Laravel starter.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "Demo mode is on — try this demo account:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Create your account",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "AI Agent",
|
|
161
162
|
"desc": "Groq-powered AI agent settings ready to configure."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Demo data",
|
|
167
|
+
"subtitle": "Rendered from src/data/demo/* — no backend needed. Set VITE_DATA_MODE=live to hit the API instead.",
|
|
168
|
+
"entities": "Entities"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "ya conectado.",
|
|
74
74
|
"tagline": "Chat en tiempo real, correo transaccional, autenticación AES-256 y un agente de IA, todo en un starter de Vue 3 + Laravel.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "El modo demo está activado — prueba esta cuenta de demostración:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Crea tu cuenta",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "Agente IA",
|
|
161
162
|
"desc": "Ajustes del agente de IA con tecnología Groq listos para configurar."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Datos de demostración",
|
|
167
|
+
"subtitle": "Se renderiza desde src/data/demo/* — no se necesita backend. Cambia VITE_DATA_MODE=live para usar la API en su lugar.",
|
|
168
|
+
"entities": "Entidades"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "déjà branché.",
|
|
74
74
|
"tagline": "Chat temps réel, e-mails transactionnels, authentification AES-256 et agent IA — réunis dans un seul starter Vue 3 + Laravel.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "Mode démo activé — essayez ce compte de démonstration :"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Créez votre compte",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "Agent IA",
|
|
161
162
|
"desc": "Paramètres de l'agent IA propulsé par Groq prêts à configurer."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Données de démonstration",
|
|
167
|
+
"subtitle": "Rendu depuis src/data/demo/* — aucun backend requis. Passez VITE_DATA_MODE=live pour utiliser l'API à la place.",
|
|
168
|
+
"entities": "Entités"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "già collegato.",
|
|
74
74
|
"tagline": "Chat in tempo reale, email transazionali, autenticazione sicura AES-256 e un agente IA — in un unico starter Vue 3 + Laravel.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "Modalità demo attiva — prova questo account demo:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Crea il tuo account",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "Agente IA",
|
|
161
162
|
"desc": "Impostazioni dell'agente IA basato su Groq pronte da configurare."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Dati demo",
|
|
167
|
+
"subtitle": "Renderizzato da src/data/demo/* — nessun backend richiesto. Imposta VITE_DATA_MODE=live per usare invece l'API.",
|
|
168
|
+
"entities": "Entità"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"headline2": "уже подключено.",
|
|
74
74
|
"tagline": "Чат в реальном времени, транзакционные письма, безопасная авторизация AES-256 и ИИ-агент — в одном стартере Vue 3 + Laravel.",
|
|
75
75
|
"footer": "Vue 3 · Laravel · Ably · Brevo · Groq"
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
"demoHint": "Демо-режим включён — попробуйте эту демо-учётную запись:"
|
|
77
78
|
},
|
|
78
79
|
"register": {
|
|
79
80
|
"title": "Создайте учётную запись",
|
|
@@ -160,6 +161,11 @@
|
|
|
160
161
|
"title": "ИИ-агент",
|
|
161
162
|
"desc": "Настройки ИИ-агента на базе Groq готовы к конфигурации."
|
|
162
163
|
}
|
|
164
|
+
},
|
|
165
|
+
"demo": {
|
|
166
|
+
"title": "Демо-данные",
|
|
167
|
+
"subtitle": "Рендерится из src/data/demo/* — бэкенд не нужен. Установите VITE_DATA_MODE=live, чтобы вместо этого использовать API.",
|
|
168
|
+
"entities": "Сущности"
|
|
163
169
|
}
|
|
164
170
|
},
|
|
165
171
|
"profile": {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Single source of truth for the app's data source.
|
|
2
|
+
//
|
|
3
|
+
// VITE_DATA_MODE=demo -> api calls are served by the in-browser demo backend
|
|
4
|
+
// (services/demoApi.js) from the bundled demo data.
|
|
5
|
+
// VITE_DATA_MODE=live -> every call goes to the Laravel backend (api.js).
|
|
6
|
+
//
|
|
7
|
+
// Stores and views import `client` (never `api` or `demoApi` directly) so the
|
|
8
|
+
// whole app flips between demo and live with one env var.
|
|
9
|
+
import api from "./api";
|
|
10
|
+
import demoApi from "./demoApi";
|
|
11
|
+
|
|
12
|
+
export const DATA_MODE = import.meta.env.VITE_DATA_MODE === "demo" ? "demo" : "live";
|
|
13
|
+
export const isDemoMode = DATA_MODE === "demo";
|
|
14
|
+
|
|
15
|
+
export const client = isDemoMode ? demoApi : api;
|
|
16
|
+
|
|
17
|
+
// Generic entity access used by pages that render data grids/lists. In live
|
|
18
|
+
// mode this maps to GET /<entity> on your backend; in demo mode it reads the
|
|
19
|
+
// corresponding src/data/demo/<entity>.js collection.
|
|
20
|
+
export function getList(entity, params) {
|
|
21
|
+
return client.get(`/${entity}`, { params });
|
|
22
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Demo-mode in-browser "backend".
|
|
2
|
+
//
|
|
3
|
+
// When VITE_DATA_MODE=demo the whole app runs on the bundled demo data with no
|
|
4
|
+
// Laravel backend or network required. This module mirrors the same routes the
|
|
5
|
+
// real API exposes — auth/me, auth/login, profile/settings writes, entity
|
|
6
|
+
// lists — and serves them from a localStorage-backed database seeded from
|
|
7
|
+
// src/data/demoAccounts.js and src/data/demo/*.js. Flip VITE_DATA_MODE=live and
|
|
8
|
+
// the app talks to the real backend again (see services/api.js).
|
|
9
|
+
//
|
|
10
|
+
// The scaffolder deletes demo modules whose features were switched off, so this
|
|
11
|
+
// file discovers them via import.meta.glob — only files that exist at build
|
|
12
|
+
// time are bundled, and nothing breaks when ones are missing.
|
|
13
|
+
import { decrypt } from "@/utils/crypto";
|
|
14
|
+
|
|
15
|
+
const DB_KEY = "falak_demo_db";
|
|
16
|
+
const TOKEN_KEY = "falak_user_token";
|
|
17
|
+
const SEED_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
// Eager glob: resolves every demo module that exists in this scaffold.
|
|
20
|
+
const entityModules = import.meta.glob("../data/demo/*.js", { eager: true });
|
|
21
|
+
const accountModules = import.meta.glob("../data/demoAccounts.js", { eager: true });
|
|
22
|
+
|
|
23
|
+
function defaultSettings() {
|
|
24
|
+
return {
|
|
25
|
+
notification_preferences: {
|
|
26
|
+
account: { email: true, push: true, sms: false, telegram: false },
|
|
27
|
+
security: { email: true, push: true, sms: true, telegram: false },
|
|
28
|
+
marketing: { email: false, push: false, sms: false, telegram: false },
|
|
29
|
+
updates: { email: true, push: false, sms: false, telegram: false },
|
|
30
|
+
},
|
|
31
|
+
two_factor_enabled: false,
|
|
32
|
+
ai_agent: {
|
|
33
|
+
enabled: false,
|
|
34
|
+
model: "llama-3.3-70b-versatile",
|
|
35
|
+
temperature: 0.7,
|
|
36
|
+
reasoning: false,
|
|
37
|
+
system_prompt: "",
|
|
38
|
+
},
|
|
39
|
+
telegram: { bot_token: "", chat_id: "", username: "" },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildCollections() {
|
|
44
|
+
const collections = {};
|
|
45
|
+
for (const [file, mod] of Object.entries(entityModules)) {
|
|
46
|
+
const name = file.split("/").pop().replace(/\.js$/, "");
|
|
47
|
+
const rows = Array.isArray(mod[name]) ? mod[name] : Array.isArray(mod.default) ? mod.default : null;
|
|
48
|
+
if (rows) collections[name] = rows.map((row) => ({ ...row }));
|
|
49
|
+
}
|
|
50
|
+
return collections;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildSeedAccounts(collections) {
|
|
54
|
+
const accounts = [];
|
|
55
|
+
let nextId = 1;
|
|
56
|
+
// Full-fidelity demo accounts (role + profile + settings) from demoAccounts.js.
|
|
57
|
+
const seeded = Object.values(accountModules)[0]?.DEMO_ACCOUNTS;
|
|
58
|
+
if (Array.isArray(seeded)) {
|
|
59
|
+
for (const a of seeded) {
|
|
60
|
+
accounts.push({
|
|
61
|
+
id: nextId++,
|
|
62
|
+
email: a.email || a.profile?.email || "",
|
|
63
|
+
password: a.password || "password",
|
|
64
|
+
role: a.role || null,
|
|
65
|
+
name: a.profile?.name || "",
|
|
66
|
+
phone: a.profile?.phone || "",
|
|
67
|
+
country_code: a.profile?.country_code || "",
|
|
68
|
+
city: a.profile?.city || "",
|
|
69
|
+
channel: a.profile?.channel || "none",
|
|
70
|
+
channel_handle: a.profile?.channel_handle || "",
|
|
71
|
+
photo: a.profile?.photo ?? null,
|
|
72
|
+
...(a.settings || defaultSettings()),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// The demo `users` entity rows are also usable as login accounts.
|
|
77
|
+
for (const u of collections.users || []) {
|
|
78
|
+
if (accounts.some((a) => a.email === u.email)) continue;
|
|
79
|
+
accounts.push({
|
|
80
|
+
id: nextId++,
|
|
81
|
+
email: u.email || "",
|
|
82
|
+
password: "password",
|
|
83
|
+
role: u.role || null,
|
|
84
|
+
name: u.name || "",
|
|
85
|
+
phone: "",
|
|
86
|
+
country_code: "",
|
|
87
|
+
city: "",
|
|
88
|
+
channel: "none",
|
|
89
|
+
channel_handle: "",
|
|
90
|
+
photo: null,
|
|
91
|
+
...defaultSettings(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return accounts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fingerprint() {
|
|
98
|
+
const entities = Object.keys(entityModules)
|
|
99
|
+
.map((f) => f.split("/").pop())
|
|
100
|
+
.sort()
|
|
101
|
+
.join(",");
|
|
102
|
+
const accounts = Object.keys(accountModules).length ? "accounts" : "";
|
|
103
|
+
return [SEED_VERSION, accounts, entities].join("|");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let db = null;
|
|
107
|
+
|
|
108
|
+
function loadDB() {
|
|
109
|
+
const fp = fingerprint();
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(localStorage.getItem(DB_KEY) || "null");
|
|
112
|
+
if (raw && raw.fingerprint === fp) {
|
|
113
|
+
db = raw;
|
|
114
|
+
return db;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Fall through to a fresh seed.
|
|
118
|
+
}
|
|
119
|
+
const collections = buildCollections();
|
|
120
|
+
db = { fingerprint: fp, accounts: buildSeedAccounts(collections), collections };
|
|
121
|
+
persist();
|
|
122
|
+
return db;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function persist() {
|
|
126
|
+
try {
|
|
127
|
+
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
|
128
|
+
} catch {
|
|
129
|
+
// Quota/storage unavailable — keep running in-memory only.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
loadDB();
|
|
134
|
+
|
|
135
|
+
function fail(message, status = 400) {
|
|
136
|
+
const error = new Error(message);
|
|
137
|
+
error.response = { status, data: { message } };
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function tokenFromStorage() {
|
|
142
|
+
const raw = localStorage.getItem(TOKEN_KEY);
|
|
143
|
+
if (!raw) return null;
|
|
144
|
+
const token = decrypt(raw);
|
|
145
|
+
const match = /^demo\.(\d+)$/.exec(token || "");
|
|
146
|
+
return match ? match[1] : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function requireAccount() {
|
|
150
|
+
const id = tokenFromStorage();
|
|
151
|
+
const account = id && db.accounts.find((a) => String(a.id) === String(id));
|
|
152
|
+
if (!account) fail("Unauthenticated", 401);
|
|
153
|
+
return account;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function publicUser(account) {
|
|
157
|
+
const { password, ...user } = account;
|
|
158
|
+
return user;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function matchEntity(path) {
|
|
162
|
+
const match = /^\/([a-z][a-z0-9-]*)(?:\/(\d+))?$/.exec(path);
|
|
163
|
+
if (!match) return null;
|
|
164
|
+
return { name: match[1], id: match[2] ? Number(match[2]) : null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getEntityRows(name) {
|
|
168
|
+
if (!db.collections[name]) db.collections[name] = [];
|
|
169
|
+
return db.collections[name];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function nextId(rows) {
|
|
173
|
+
return rows.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0) + 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function base32Secret() {
|
|
177
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
178
|
+
let out = "";
|
|
179
|
+
for (let i = 0; i < 16; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
180
|
+
return out.match(/.{4}/g).join(" ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function recoveryCodes() {
|
|
184
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
185
|
+
const codes = new Set();
|
|
186
|
+
while (codes.size < 6) {
|
|
187
|
+
let code = "";
|
|
188
|
+
for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
|
189
|
+
code += "-";
|
|
190
|
+
for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
|
191
|
+
codes.add(code);
|
|
192
|
+
}
|
|
193
|
+
return [...codes];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function overview() {
|
|
197
|
+
const stats = {
|
|
198
|
+
users: db.accounts.length,
|
|
199
|
+
orders: (db.collections.orders || []).length,
|
|
200
|
+
products: (db.collections.products || []).length,
|
|
201
|
+
revenue: (db.collections.orders || []).reduce((sum, o) => sum + (Number(o.total) || 0), 0),
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
mode: "demo",
|
|
205
|
+
stats,
|
|
206
|
+
entities: Object.keys(db.collections).map((name) => {
|
|
207
|
+
const rows = getEntityRows(name);
|
|
208
|
+
const first = rows[0];
|
|
209
|
+
let preview = "";
|
|
210
|
+
if (first) {
|
|
211
|
+
preview = Object.entries(first)
|
|
212
|
+
.filter(([key]) => key !== "id")
|
|
213
|
+
.slice(0, 3)
|
|
214
|
+
.map(([, value]) => String(value))
|
|
215
|
+
.join(" · ");
|
|
216
|
+
}
|
|
217
|
+
return { name, count: rows.length, preview };
|
|
218
|
+
}),
|
|
219
|
+
signInHint: {
|
|
220
|
+
email: db.accounts[0]?.email || "",
|
|
221
|
+
password: db.accounts[0]?.password || "password",
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const demoApi = {
|
|
227
|
+
async get(url) {
|
|
228
|
+
const path = (url || "").split("?")[0];
|
|
229
|
+
if (path === "/auth/me") return { data: publicUser(requireAccount()) };
|
|
230
|
+
if (path === "/overview") return { data: overview() };
|
|
231
|
+
const entity = matchEntity(path);
|
|
232
|
+
if (entity) return { data: [...getEntityRows(entity.name)] };
|
|
233
|
+
fail(`Demo backend: no GET handler for ${path}`, 404);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async post(url, body = {}) {
|
|
237
|
+
const path = (url || "").split("?")[0];
|
|
238
|
+
switch (path) {
|
|
239
|
+
case "/auth/login": {
|
|
240
|
+
const identifier = String(body.identifier || "").trim().toLowerCase();
|
|
241
|
+
const password = String(body.password || "");
|
|
242
|
+
const account = db.accounts.find(
|
|
243
|
+
(a) =>
|
|
244
|
+
a.email?.toLowerCase() === identifier || String(a.phone || "").trim() === String(body.identifier || "").trim()
|
|
245
|
+
);
|
|
246
|
+
if (!account || account.password !== password) fail("Invalid credentials", 401);
|
|
247
|
+
return { data: { token: `demo.${account.id}`, user: publicUser(account) } };
|
|
248
|
+
}
|
|
249
|
+
case "/auth/register": {
|
|
250
|
+
const email = String(body.email || "").trim().toLowerCase();
|
|
251
|
+
if (db.accounts.some((a) => a.email?.toLowerCase() === email)) fail("Email already registered", 422);
|
|
252
|
+
const account = {
|
|
253
|
+
id: nextId(db.accounts),
|
|
254
|
+
email,
|
|
255
|
+
phone: String(body.phone || "").trim(),
|
|
256
|
+
name: String(body.name || "").trim(),
|
|
257
|
+
role: body.role ?? null,
|
|
258
|
+
password: String(body.password || "password"),
|
|
259
|
+
channel: "none",
|
|
260
|
+
channel_handle: "",
|
|
261
|
+
photo: null,
|
|
262
|
+
...defaultSettings(),
|
|
263
|
+
};
|
|
264
|
+
db.accounts.push(account);
|
|
265
|
+
persist();
|
|
266
|
+
return { data: { token: `demo.${account.id}`, user: publicUser(account) } };
|
|
267
|
+
}
|
|
268
|
+
case "/auth/logout":
|
|
269
|
+
return { data: {} };
|
|
270
|
+
case "/auth/forgot-password":
|
|
271
|
+
return { data: {} };
|
|
272
|
+
case "/auth/reset-password":
|
|
273
|
+
return { data: {} };
|
|
274
|
+
case "/auth/change-password": {
|
|
275
|
+
const account = requireAccount();
|
|
276
|
+
account.password = String(body.new_password || account.password);
|
|
277
|
+
persist();
|
|
278
|
+
return { data: {} };
|
|
279
|
+
}
|
|
280
|
+
case "/auth/2fa/enable": {
|
|
281
|
+
const account = requireAccount();
|
|
282
|
+
account.two_factor_method = String(body.method || "app");
|
|
283
|
+
account.two_factor_secret = base32Secret();
|
|
284
|
+
account.two_factor_challenge = true;
|
|
285
|
+
persist();
|
|
286
|
+
return { data: { secret: account.two_factor_secret, challenge: account.two_factor_challenge } };
|
|
287
|
+
}
|
|
288
|
+
case "/auth/2fa/verify": {
|
|
289
|
+
const account = requireAccount();
|
|
290
|
+
account.two_factor_enabled = true;
|
|
291
|
+
account.two_factor_challenge = false;
|
|
292
|
+
account.two_factor_recovery_codes = recoveryCodes();
|
|
293
|
+
persist();
|
|
294
|
+
return { data: publicUser(account) };
|
|
295
|
+
}
|
|
296
|
+
case "/auth/2fa/disable": {
|
|
297
|
+
const account = requireAccount();
|
|
298
|
+
account.two_factor_enabled = false;
|
|
299
|
+
account.two_factor_secret = "";
|
|
300
|
+
account.two_factor_challenge = false;
|
|
301
|
+
persist();
|
|
302
|
+
return { data: publicUser(account) };
|
|
303
|
+
}
|
|
304
|
+
case "/auth/telegram/test":
|
|
305
|
+
// Demo: always succeeds so the dialog can be exercised to completion.
|
|
306
|
+
return { data: { ok: true, message: "Sent (demo)" } };
|
|
307
|
+
default: {
|
|
308
|
+
const social = /^\/auth\/social\/([a-z]+)$/.exec(path);
|
|
309
|
+
if (social) return { data: { url: "" } }; // never navigate away in demo mode
|
|
310
|
+
const entity = matchEntity(path);
|
|
311
|
+
if (entity) {
|
|
312
|
+
const rows = getEntityRows(entity.name);
|
|
313
|
+
const record = { id: nextId(rows), ...body };
|
|
314
|
+
rows.push(record);
|
|
315
|
+
persist();
|
|
316
|
+
return { data: record };
|
|
317
|
+
}
|
|
318
|
+
fail(`Demo backend: no POST handler for ${path}`, 404);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
async put(url, body = {}) {
|
|
324
|
+
const path = (url || "").split("?")[0];
|
|
325
|
+
if (path === "/auth/me") {
|
|
326
|
+
const account = requireAccount();
|
|
327
|
+
for (const key of ["name", "email", "phone", "country_code", "city", "channel", "channel_handle", "photo"]) {
|
|
328
|
+
if (key in body) account[key] = body[key];
|
|
329
|
+
}
|
|
330
|
+
persist();
|
|
331
|
+
return { data: publicUser(account) };
|
|
332
|
+
}
|
|
333
|
+
if (path === "/auth/notifications") {
|
|
334
|
+
const account = requireAccount();
|
|
335
|
+
account.notification_preferences = body.preferences || account.notification_preferences;
|
|
336
|
+
persist();
|
|
337
|
+
return { data: publicUser(account) };
|
|
338
|
+
}
|
|
339
|
+
if (path === "/auth/ai-agent") {
|
|
340
|
+
const account = requireAccount();
|
|
341
|
+
account.ai_agent = { ...account.ai_agent, ...body };
|
|
342
|
+
persist();
|
|
343
|
+
return { data: publicUser(account) };
|
|
344
|
+
}
|
|
345
|
+
if (path === "/auth/telegram") {
|
|
346
|
+
const account = requireAccount();
|
|
347
|
+
account.telegram = { ...account.telegram, ...body };
|
|
348
|
+
persist();
|
|
349
|
+
return { data: publicUser(account) };
|
|
350
|
+
}
|
|
351
|
+
const entity = matchEntity(path);
|
|
352
|
+
if (entity?.id !== null) {
|
|
353
|
+
const rows = getEntityRows(entity.name);
|
|
354
|
+
const index = rows.findIndex((row) => Number(row.id) === entity.id);
|
|
355
|
+
if (index === -1) fail("Not found", 404);
|
|
356
|
+
rows[index] = { ...rows[index], ...body, id: entity.id };
|
|
357
|
+
persist();
|
|
358
|
+
return { data: rows[index] };
|
|
359
|
+
}
|
|
360
|
+
fail(`Demo backend: no PUT handler for ${path}`, 404);
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
async delete(url) {
|
|
364
|
+
const entity = matchEntity((url || "").split("?")[0]);
|
|
365
|
+
if (entity?.id !== null) {
|
|
366
|
+
const rows = getEntityRows(entity.name);
|
|
367
|
+
const index = rows.findIndex((row) => Number(row.id) === entity.id);
|
|
368
|
+
if (index === -1) fail("Not found", 404);
|
|
369
|
+
rows.splice(index, 1);
|
|
370
|
+
persist();
|
|
371
|
+
return { data: { ok: true } };
|
|
372
|
+
}
|
|
373
|
+
fail(`Demo backend: no DELETE handler for ${url}`, 404);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export default demoApi;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from "pinia";
|
|
2
2
|
import { encrypt, decrypt } from "@/utils/crypto";
|
|
3
|
-
import
|
|
3
|
+
import { client } from "@/services/dataClient";
|
|
4
4
|
|
|
5
5
|
const STORAGE_KEY = "falak_user_token";
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@ export const useAuthStore = defineStore("auth", {
|
|
|
39
39
|
async login(identifier, password) {
|
|
40
40
|
// `identifier` is either an email or a phone number — send it as "identifier"
|
|
41
41
|
// so the backend can decide, or normalize it here depending on your API.
|
|
42
|
-
const { data } = await
|
|
42
|
+
const { data } = await client.post("/auth/login", {
|
|
43
43
|
identifier: identifier.trim(),
|
|
44
44
|
password,
|
|
45
45
|
});
|
|
@@ -49,20 +49,20 @@ export const useAuthStore = defineStore("auth", {
|
|
|
49
49
|
},
|
|
50
50
|
async logout() {
|
|
51
51
|
try {
|
|
52
|
-
await
|
|
52
|
+
await client.post("/auth/logout");
|
|
53
53
|
} finally {
|
|
54
54
|
this.clearToken();
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
async fetchProfile() {
|
|
58
|
-
const { data } = await
|
|
58
|
+
const { data } = await client.get("/auth/me");
|
|
59
59
|
this.user = data;
|
|
60
60
|
return data;
|
|
61
61
|
},
|
|
62
62
|
|
|
63
63
|
// ---- Create account / recover password ----
|
|
64
64
|
async register(payload) {
|
|
65
|
-
const { data } = await
|
|
65
|
+
const { data } = await client.post("/auth/register", payload);
|
|
66
66
|
if (data.token) {
|
|
67
67
|
this.setToken(data.token);
|
|
68
68
|
this.user = data.user;
|
|
@@ -70,26 +70,26 @@ export const useAuthStore = defineStore("auth", {
|
|
|
70
70
|
return data;
|
|
71
71
|
},
|
|
72
72
|
async forgotPassword(identifier) {
|
|
73
|
-
return
|
|
73
|
+
return client.post("/auth/forgot-password", { identifier });
|
|
74
74
|
},
|
|
75
75
|
async resetPassword(payload) {
|
|
76
|
-
return
|
|
76
|
+
return client.post("/auth/reset-password", payload);
|
|
77
77
|
},
|
|
78
78
|
// Social login: opens the provider's OAuth URL returned by the backend.
|
|
79
79
|
async socialLogin(provider) {
|
|
80
|
-
const { data } = await
|
|
80
|
+
const { data } = await client.post(`/auth/social/${provider}`);
|
|
81
81
|
if (data?.url) window.location.href = data.url;
|
|
82
82
|
return data;
|
|
83
83
|
},
|
|
84
84
|
|
|
85
85
|
// ---- Profile ----
|
|
86
86
|
async updateProfile(payload) {
|
|
87
|
-
const { data } = await
|
|
87
|
+
const { data } = await client.put("/auth/me", payload);
|
|
88
88
|
this.user = data;
|
|
89
89
|
return data;
|
|
90
90
|
},
|
|
91
91
|
async changePassword(currentPassword, newPassword) {
|
|
92
|
-
return
|
|
92
|
+
return client.post("/auth/change-password", {
|
|
93
93
|
current_password: currentPassword,
|
|
94
94
|
new_password: newPassword,
|
|
95
95
|
});
|
|
@@ -97,38 +97,38 @@ export const useAuthStore = defineStore("auth", {
|
|
|
97
97
|
|
|
98
98
|
// ---- 2FA ----
|
|
99
99
|
async enable2FA(method) {
|
|
100
|
-
const { data } = await
|
|
100
|
+
const { data } = await client.post("/auth/2fa/enable", { method });
|
|
101
101
|
return data;
|
|
102
102
|
},
|
|
103
103
|
async verify2FA(code) {
|
|
104
|
-
const { data } = await
|
|
104
|
+
const { data } = await client.post("/auth/2fa/verify", { code });
|
|
105
105
|
this.user = data;
|
|
106
106
|
return data;
|
|
107
107
|
},
|
|
108
108
|
async disable2FA(code) {
|
|
109
|
-
const { data } = await
|
|
109
|
+
const { data } = await client.post("/auth/2fa/disable", { code });
|
|
110
110
|
this.user = data;
|
|
111
111
|
return data;
|
|
112
112
|
},
|
|
113
113
|
|
|
114
114
|
// ---- Settings ----
|
|
115
115
|
async updateNotifications(preferences) {
|
|
116
|
-
const { data } = await
|
|
116
|
+
const { data } = await client.put("/auth/notifications", { preferences });
|
|
117
117
|
this.user = data;
|
|
118
118
|
return data;
|
|
119
119
|
},
|
|
120
120
|
async updateAIAgent(config) {
|
|
121
|
-
const { data } = await
|
|
121
|
+
const { data } = await client.put("/auth/ai-agent", config);
|
|
122
122
|
this.user = data;
|
|
123
123
|
return data;
|
|
124
124
|
},
|
|
125
125
|
async updateTelegram(config) {
|
|
126
|
-
const { data } = await
|
|
126
|
+
const { data } = await client.put("/auth/telegram", config);
|
|
127
127
|
this.user = data;
|
|
128
128
|
return data;
|
|
129
129
|
},
|
|
130
130
|
async testTelegram() {
|
|
131
|
-
return
|
|
131
|
+
return client.post("/auth/telegram/test");
|
|
132
132
|
},
|
|
133
133
|
},
|
|
134
134
|
});
|
|
@@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n";
|
|
|
4
4
|
import { RouterLink } from "vue-router";
|
|
5
5
|
import { useAuthStore } from "@/stores/auth";
|
|
6
6
|
import GlassCard from "@/components/GlassCard.vue";
|
|
7
|
+
import { isDemoMode, client } from "@/services/dataClient";
|
|
7
8
|
|
|
8
9
|
const { t } = useI18n();
|
|
9
10
|
const auth = useAuthStore();
|
|
@@ -24,6 +25,17 @@ const words = computed(() => [
|
|
|
24
25
|
|
|
25
26
|
const chips = computed(() => ["Vue 3", "Laravel", "Tailwind", "Ably", "Brevo", "Pinia", "vue-i18n", "Groq"]);
|
|
26
27
|
|
|
28
|
+
// Demo mode: the hero stats are derived from the bundled demo data and a
|
|
29
|
+
// live "demo data" section shows the entities the scaffold shipped.
|
|
30
|
+
const demoOverview = ref(null);
|
|
31
|
+
|
|
32
|
+
// 1 = starter users, 2 = uptime, 3 = services wired, 4 = support
|
|
33
|
+
const stats = computed(() => {
|
|
34
|
+
if (!isDemoMode || !demoOverview.value) return ["12k+", "99.9%", "10", "24/7"];
|
|
35
|
+
const s = demoOverview.value.stats;
|
|
36
|
+
return [String(s.users), "99.9%", String(demoOverview.value.entities.length), "24/7"];
|
|
37
|
+
});
|
|
38
|
+
|
|
27
39
|
// Typewriter
|
|
28
40
|
const typed = ref("");
|
|
29
41
|
const wordIndex = ref(0);
|
|
@@ -51,8 +63,15 @@ function tick() {
|
|
|
51
63
|
timeout = setTimeout(tick, 30);
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
|
-
onMounted(() => {
|
|
66
|
+
onMounted(async () => {
|
|
55
67
|
timeout = setTimeout(tick, 700);
|
|
68
|
+
if (isDemoMode) {
|
|
69
|
+
try {
|
|
70
|
+
demoOverview.value = (await client.get("/overview")).data;
|
|
71
|
+
} catch {
|
|
72
|
+
// The demo backend is always available — nothing to do on failure.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
});
|
|
57
76
|
onUnmounted(() => clearTimeout(timeout));
|
|
58
77
|
</script>
|
|
@@ -118,7 +137,7 @@ onUnmounted(() => clearTimeout(timeout));
|
|
|
118
137
|
|
|
119
138
|
<!-- Stats strip -->
|
|
120
139
|
<div class="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
121
|
-
<div v-for="(s, i) in
|
|
140
|
+
<div v-for="(s, i) in stats" :key="i" class="animate-fade-in-up motion-reduce:animate-none">
|
|
122
141
|
<GlassCard class="p-5 text-center glass-card-hover">
|
|
123
142
|
<p class="text-2xl font-extrabold text-gradient">{{ s }}</p>
|
|
124
143
|
<p class="mt-1 text-xs text-gray-500">{{ t(`home.stats.${i + 1}`) }}</p>
|
|
@@ -126,6 +145,37 @@ onUnmounted(() => clearTimeout(timeout));
|
|
|
126
145
|
</div>
|
|
127
146
|
</div>
|
|
128
147
|
|
|
148
|
+
<!-- Demo data (only when the app runs in demo mode) -->
|
|
149
|
+
<div v-if="isDemoMode && demoOverview" class="mt-6 animate-fade-in-up motion-reduce:animate-none">
|
|
150
|
+
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
151
|
+
<div>
|
|
152
|
+
<h2 class="section-head flex items-center gap-2">
|
|
153
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-teal-300">
|
|
154
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
155
|
+
<path d="M3 9h18M8 4v5M16 4v5" />
|
|
156
|
+
</svg>
|
|
157
|
+
{{ t("home.demo.title") }}
|
|
158
|
+
</h2>
|
|
159
|
+
<p class="mt-1 text-sm text-gray-400">{{ t("home.demo.subtitle") }}</p>
|
|
160
|
+
</div>
|
|
161
|
+
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-300 ring-1 ring-emerald-400/20">
|
|
162
|
+
{{ demoOverview.entities.length }} {{ t("home.demo.entities") }}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
166
|
+
<GlassCard v-for="e in demoOverview.entities" :key="e.name" hover class="p-5">
|
|
167
|
+
<div class="flex items-center justify-between gap-3">
|
|
168
|
+
<span class="rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 font-mono text-xs font-semibold text-teal-200">
|
|
169
|
+
{{ e.name }}
|
|
170
|
+
</span>
|
|
171
|
+
<span class="text-lg font-extrabold text-white">{{ e.count }}</span>
|
|
172
|
+
</div>
|
|
173
|
+
<p v-if="e.preview" class="mt-3 truncate text-xs text-gray-500" :title="e.preview">{{ e.preview }}</p>
|
|
174
|
+
<p v-else class="mt-3 text-xs text-gray-600">—</p>
|
|
175
|
+
</GlassCard>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
129
179
|
<!-- Features -->
|
|
130
180
|
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:mt-10">
|
|
131
181
|
<GlassCard
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, watch } from "vue";
|
|
2
|
+
import { onMounted, ref, watch } from "vue";
|
|
3
3
|
import { useI18n } from "vue-i18n";
|
|
4
4
|
import { useRouter, RouterLink } from "vue-router";
|
|
5
5
|
import { useAuthStore } from "@/stores/auth";
|
|
6
6
|
import AuthLayout from "@/components/AuthLayout.vue";
|
|
7
7
|
import SocialIcon from "@/components/SocialIcon.vue";
|
|
8
8
|
import { useRipple } from "@/composables/useRipple";
|
|
9
|
+
import { isDemoMode, client } from "@/services/dataClient";
|
|
9
10
|
|
|
10
11
|
const { t } = useI18n();
|
|
11
12
|
const router = useRouter();
|
|
@@ -24,6 +25,19 @@ const socialBusy = ref("");
|
|
|
24
25
|
|
|
25
26
|
const { ripples, spawnRipple, removeRipple } = useRipple();
|
|
26
27
|
|
|
28
|
+
// In demo mode, surface a ready-to-use account (from demoAccounts / demo data)
|
|
29
|
+
// so the login flow can be exercised without a backend.
|
|
30
|
+
const demoHint = ref(null);
|
|
31
|
+
onMounted(async () => {
|
|
32
|
+
if (!isDemoMode) return;
|
|
33
|
+
try {
|
|
34
|
+
const { data } = await client.get("/overview");
|
|
35
|
+
demoHint.value = data.signInHint || null;
|
|
36
|
+
} catch {
|
|
37
|
+
demoHint.value = null;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
27
41
|
const savedIdentifier = localStorage.getItem(REMEMBER_KEY) || "";
|
|
28
42
|
if (savedIdentifier) {
|
|
29
43
|
identifier.value = savedIdentifier;
|
|
@@ -114,6 +128,17 @@ async function social(provider) {
|
|
|
114
128
|
<span class="h-px flex-1 bg-gradient-to-r from-transparent via-white/15 to-transparent"></span>
|
|
115
129
|
</div>
|
|
116
130
|
|
|
131
|
+
<!-- Demo-mode account hint -->
|
|
132
|
+
<div
|
|
133
|
+
v-if="isDemoMode && demoHint"
|
|
134
|
+
class="mb-5 rounded-xl border border-emerald-300/25 bg-emerald-500/10 px-4 py-3 text-sm"
|
|
135
|
+
>
|
|
136
|
+
<p class="font-medium text-emerald-200">{{ t("auth.demoHint") }}</p>
|
|
137
|
+
<code class="mt-1 block font-mono text-xs text-emerald-300">
|
|
138
|
+
{{ demoHint.email }} / {{ demoHint.password }}
|
|
139
|
+
</code>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
117
142
|
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
|
118
143
|
<label class="block">
|
|
119
144
|
<span class="field-label">
|