feat: add Umami analytics tracking across web app
Add centralized event tracking with consistent naming convention. Tracks auth flows, license activation, device interactions, API key management, settings, and navigation for conversion analysis.
This commit is contained in:
@@ -3,7 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Text:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
<script defer src="https://track.thisux.com/script.js" data-website-id="b48faade-b8b9-4f2c-b8bc-9a5699639e47"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
|||||||
41
web/src/lib/analytics/events.ts
Normal file
41
web/src/lib/analytics/events.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Umami analytics event names.
|
||||||
|
*
|
||||||
|
* Naming convention: {category}-{action}
|
||||||
|
* Use these constants everywhere to keep tracking consistent.
|
||||||
|
* Umami auto-tracks page views — these are for custom events only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Auth ────────────────────────────────────────────
|
||||||
|
export const AUTH_LOGIN_SUBMIT = 'auth-login-submit';
|
||||||
|
export const AUTH_LOGIN_SUCCESS = 'auth-login-success';
|
||||||
|
export const AUTH_SIGNUP_SUBMIT = 'auth-signup-submit';
|
||||||
|
export const AUTH_SIGNUP_SUCCESS = 'auth-signup-success';
|
||||||
|
export const AUTH_SIGNOUT = 'auth-signout';
|
||||||
|
|
||||||
|
// ─── License / Conversion ────────────────────────────
|
||||||
|
export const LICENSE_ACTIVATE_CHECKOUT = 'license-activate-checkout';
|
||||||
|
export const LICENSE_ACTIVATE_MANUAL = 'license-activate-manual';
|
||||||
|
export const LICENSE_PURCHASE_CLICK = 'license-purchase-click';
|
||||||
|
|
||||||
|
// ─── Dashboard ───────────────────────────────────────
|
||||||
|
export const DASHBOARD_CARD_CLICK = 'dashboard-card-click';
|
||||||
|
|
||||||
|
// ─── Devices ─────────────────────────────────────────
|
||||||
|
export const DEVICE_CARD_CLICK = 'device-card-click';
|
||||||
|
export const DEVICE_TAB_CHANGE = 'device-tab-change';
|
||||||
|
export const DEVICE_GOAL_SUBMIT = 'device-goal-submit';
|
||||||
|
export const DEVICE_GOAL_STOP = 'device-goal-stop';
|
||||||
|
export const DEVICE_GOAL_COMPLETE = 'device-goal-complete';
|
||||||
|
export const DEVICE_SESSION_EXPAND = 'device-session-expand';
|
||||||
|
|
||||||
|
// ─── API Keys ────────────────────────────────────────
|
||||||
|
export const APIKEY_CREATE = 'apikey-create';
|
||||||
|
export const APIKEY_COPY = 'apikey-copy';
|
||||||
|
export const APIKEY_DELETE = 'apikey-delete';
|
||||||
|
|
||||||
|
// ─── Settings ────────────────────────────────────────
|
||||||
|
export const SETTINGS_SAVE = 'settings-save';
|
||||||
|
|
||||||
|
// ─── Navigation ──────────────────────────────────────
|
||||||
|
export const NAV_SIDEBAR_CLICK = 'nav-sidebar-click';
|
||||||
19
web/src/lib/analytics/track.ts
Normal file
19
web/src/lib/analytics/track.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Thin wrapper around Umami's tracking API.
|
||||||
|
* Use for programmatic event tracking (form success callbacks, etc.).
|
||||||
|
* For click tracking on buttons/links, prefer data-umami-event attributes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (event: string, data?: Record<string, string | number | boolean>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function track(event: string, data?: Record<string, string | number | boolean>) {
|
||||||
|
if (typeof window !== 'undefined' && window.umami) {
|
||||||
|
window.umami.track(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { DEVICE_CARD_CLICK } from '$lib/analytics/events';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,16 +44,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function batteryIcon(level: number | null, charging: boolean): string {
|
function batteryIcon(level: number | null, charging: boolean): string {
|
||||||
if (level === null || level < 0) return '?';
|
if (level === null || level < 0) return 'ph:battery-empty-duotone';
|
||||||
if (charging) return '⚡';
|
if (charging) return 'ph:battery-charging-duotone';
|
||||||
if (level > 75) return '█';
|
if (level > 75) return 'ph:battery-full-duotone';
|
||||||
if (level > 50) return '▆';
|
if (level > 50) return 'ph:battery-high-duotone';
|
||||||
if (level > 25) return '▄';
|
if (level > 25) return 'ph:battery-medium-duotone';
|
||||||
return '▂';
|
return 'ph:battery-low-duotone';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/dashboard/devices/{deviceId}" class="group block">
|
<a href="/dashboard/devices/{deviceId}" data-umami-event={DEVICE_CARD_CLICK} data-umami-event-device={model ?? name} class="group block">
|
||||||
<!-- Phone frame -->
|
<!-- Phone frame -->
|
||||||
<div
|
<div
|
||||||
class="relative mx-auto w-48 overflow-hidden rounded-[2rem] border-2 bg-white transition-all
|
class="relative mx-auto w-48 overflow-hidden rounded-[2rem] border-2 bg-white transition-all
|
||||||
@@ -79,9 +82,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if batteryLevel !== null && batteryLevel >= 0}
|
{#if batteryLevel !== null && batteryLevel >= 0}
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<span class="text-[10px] {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}">
|
<Icon
|
||||||
{batteryIcon(batteryLevel, isCharging)}
|
icon={batteryIcon(batteryLevel, isCharging)}
|
||||||
</span>
|
class="h-3.5 w-3.5 {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}"
|
||||||
|
/>
|
||||||
<span class="text-[10px] {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}">
|
<span class="text-[10px] {batteryLevel <= 20 ? 'text-red-500' : 'text-neutral-400'}">
|
||||||
{batteryLevel}%
|
{batteryLevel}%
|
||||||
</span>
|
</span>
|
||||||
@@ -93,19 +97,7 @@
|
|||||||
<div class="flex flex-1 flex-col items-center px-4 pt-4">
|
<div class="flex flex-1 flex-col items-center px-4 pt-4">
|
||||||
<!-- Device icon -->
|
<!-- Device icon -->
|
||||||
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-neutral-100">
|
<div class="mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-neutral-100">
|
||||||
<svg
|
<Icon icon="ph:device-mobile-duotone" class="h-5 w-5 text-neutral-500" />
|
||||||
class="h-5 w-5 text-neutral-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device name -->
|
<!-- Device name -->
|
||||||
@@ -139,12 +131,20 @@
|
|||||||
<p class="truncate text-[10px] text-neutral-600">{lastGoal.goal}</p>
|
<p class="truncate text-[10px] text-neutral-600">{lastGoal.goal}</p>
|
||||||
<div class="mt-0.5 flex items-center justify-between">
|
<div class="mt-0.5 flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
class="text-[9px] font-medium {lastGoal.status === 'completed'
|
class="flex items-center gap-0.5 text-[9px] font-medium {lastGoal.status === 'completed'
|
||||||
? 'text-green-600'
|
? 'text-green-600'
|
||||||
: lastGoal.status === 'running'
|
: lastGoal.status === 'running'
|
||||||
? 'text-amber-600'
|
? 'text-amber-600'
|
||||||
: 'text-red-500'}"
|
: 'text-red-500'}"
|
||||||
>
|
>
|
||||||
|
<Icon
|
||||||
|
icon={lastGoal.status === 'completed'
|
||||||
|
? 'ph:check-circle-duotone'
|
||||||
|
: lastGoal.status === 'running'
|
||||||
|
? 'ph:circle-notch-duotone'
|
||||||
|
: 'ph:x-circle-duotone'}
|
||||||
|
class="h-3 w-3"
|
||||||
|
/>
|
||||||
{lastGoal.status === 'completed'
|
{lastGoal.status === 'completed'
|
||||||
? 'Success'
|
? 'Success'
|
||||||
: lastGoal.status === 'running'
|
: lastGoal.status === 'running'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
import { AUTH_SIGNOUT, NAV_SIDEBAR_CLICK } from '$lib/analytics/events';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@
|
|||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
data-umami-event={NAV_SIDEBAR_CLICK}
|
||||||
|
data-umami-event-section={item.label.toLowerCase().replace(' ', '-')}
|
||||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
|
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
|
||||||
{isActive(item.href, item.exact)
|
{isActive(item.href, item.exact)
|
||||||
? 'bg-neutral-200/70 text-neutral-900'
|
? 'bg-neutral-200/70 text-neutral-900'
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
<form {...signout}>
|
<form {...signout}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-umami-event={AUTH_SIGNOUT}
|
||||||
class="mt-1 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
class="mt-1 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:sign-out-duotone" class="h-5 w-5" />
|
<Icon icon="ph:sign-out-duotone" class="h-5 w-5" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
import { DASHBOARD_CARD_CLICK } from '$lib/analytics/events';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@
|
|||||||
{#each cards as card}
|
{#each cards as card}
|
||||||
<a
|
<a
|
||||||
href={card.href}
|
href={card.href}
|
||||||
|
data-umami-event={DASHBOARD_CARD_CLICK}
|
||||||
|
data-umami-event-section={card.title.toLowerCase().replace(' ', '-')}
|
||||||
class="group flex items-start gap-4 rounded-xl border border-neutral-200 p-5 transition-all hover:border-neutral-300 hover:shadow-sm"
|
class="group flex items-start gap-4 rounded-xl border border-neutral-200 p-5 transition-all hover:border-neutral-300 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {card.color}">
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {card.color}">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { activateLicense, activateFromCheckout } from '$lib/api/license.remote';
|
import { activateLicense, activateFromCheckout } from '$lib/api/license.remote';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
import { LICENSE_ACTIVATE_CHECKOUT, LICENSE_ACTIVATE_MANUAL, LICENSE_PURCHASE_CLICK } from '$lib/analytics/events';
|
||||||
|
|
||||||
const checkoutId = page.url.searchParams.get('checkout_id');
|
const checkoutId = page.url.searchParams.get('checkout_id');
|
||||||
</script>
|
</script>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<input type="hidden" {...activateFromCheckout.fields.checkoutId.as('text')} value={checkoutId} />
|
<input type="hidden" {...activateFromCheckout.fields.checkoutId.as('text')} value={checkoutId} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-umami-event={LICENSE_ACTIVATE_CHECKOUT}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-umami-event={LICENSE_ACTIVATE_MANUAL}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
||||||
@@ -96,6 +99,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-umami-event={LICENSE_ACTIVATE_MANUAL}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
|
||||||
@@ -105,7 +109,7 @@
|
|||||||
|
|
||||||
<p class="mt-6 text-center text-sm text-neutral-400">
|
<p class="mt-6 text-center text-sm text-neutral-400">
|
||||||
Don't have a key?
|
Don't have a key?
|
||||||
<a href="https://sandbox-api.polar.sh/v1/checkout-links/polar_cl_5pGavRIJJhM8ge6p0UaeaadT2bCiqL04CYXgW3bwVac/redirect" class="font-medium text-neutral-700 underline hover:text-neutral-900">
|
<a href="https://sandbox-api.polar.sh/v1/checkout-links/polar_cl_5pGavRIJJhM8ge6p0UaeaadT2bCiqL04CYXgW3bwVac/redirect" data-umami-event={LICENSE_PURCHASE_CLICK} class="font-medium text-neutral-700 underline hover:text-neutral-900">
|
||||||
Purchase here
|
Purchase here
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { listKeys, createKey, deleteKey } from '$lib/api/api-keys.remote';
|
import { listKeys, createKey, deleteKey } from '$lib/api/api-keys.remote';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
|
import { track } from '$lib/analytics/track';
|
||||||
|
import { APIKEY_CREATE, APIKEY_COPY, APIKEY_DELETE } from '$lib/analytics/events';
|
||||||
|
|
||||||
let newKeyValue = $state<string | null>(null);
|
let newKeyValue = $state<string | null>(null);
|
||||||
let keysPromise = $state(listKeys());
|
let keysPromise = $state(listKeys());
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
newKeyValue = createKey.result.key;
|
newKeyValue = createKey.result.key;
|
||||||
keysPromise = listKeys();
|
keysPromise = listKeys();
|
||||||
toast.success('API key created');
|
toast.success('API key created');
|
||||||
|
track(APIKEY_CREATE);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
if (deleteKey.result?.deleted) {
|
if (deleteKey.result?.deleted) {
|
||||||
keysPromise = listKeys();
|
keysPromise = listKeys();
|
||||||
toast.success('API key deleted');
|
toast.success('API key deleted');
|
||||||
|
track(APIKEY_DELETE);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -70,6 +74,7 @@
|
|||||||
onclick={() => {
|
onclick={() => {
|
||||||
navigator.clipboard.writeText(newKeyValue!);
|
navigator.clipboard.writeText(newKeyValue!);
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
|
track(APIKEY_COPY);
|
||||||
}}
|
}}
|
||||||
class="flex items-center gap-1.5 rounded-lg border border-yellow-400 px-3 py-2 text-sm font-medium text-yellow-800 hover:bg-yellow-100"
|
class="flex items-center gap-1.5 rounded-lg border border-yellow-400 px-3 py-2 text-sm font-medium text-yellow-800 hover:bg-yellow-100"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,12 +10,27 @@
|
|||||||
} from '$lib/api/devices.remote';
|
} from '$lib/api/devices.remote';
|
||||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { track } from '$lib/analytics/track';
|
||||||
|
import {
|
||||||
|
DEVICE_TAB_CHANGE,
|
||||||
|
DEVICE_GOAL_SUBMIT,
|
||||||
|
DEVICE_GOAL_STOP,
|
||||||
|
DEVICE_GOAL_COMPLETE,
|
||||||
|
DEVICE_SESSION_EXPAND
|
||||||
|
} from '$lib/analytics/events';
|
||||||
|
|
||||||
const deviceId = page.params.deviceId!;
|
const deviceId = page.params.deviceId!;
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
let activeTab = $state<'overview' | 'sessions' | 'run'>('overview');
|
let activeTab = $state<'overview' | 'sessions' | 'run'>('overview');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview' as const, label: 'Overview', icon: 'ph:info-duotone' },
|
||||||
|
{ id: 'sessions' as const, label: 'Sessions', icon: 'ph:clock-counter-clockwise-duotone' },
|
||||||
|
{ id: 'run' as const, label: 'Run', icon: 'ph:play-duotone' }
|
||||||
|
];
|
||||||
|
|
||||||
// Device data from DB
|
// Device data from DB
|
||||||
const deviceData = (await getDevice(deviceId)) as {
|
const deviceData = (await getDevice(deviceId)) as {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -76,6 +91,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expandedSession = sessionId;
|
expandedSession = sessionId;
|
||||||
|
track(DEVICE_SESSION_EXPAND);
|
||||||
if (!sessionSteps.has(sessionId)) {
|
if (!sessionSteps.has(sessionId)) {
|
||||||
const loadedSteps = await listSessionSteps({ deviceId, sessionId });
|
const loadedSteps = await listSessionSteps({ deviceId, sessionId });
|
||||||
sessionSteps.set(sessionId, loadedSteps as Step[]);
|
sessionSteps.set(sessionId, loadedSteps as Step[]);
|
||||||
@@ -91,6 +107,7 @@
|
|||||||
runError = '';
|
runError = '';
|
||||||
currentGoal = goal;
|
currentGoal = goal;
|
||||||
steps = [];
|
steps = [];
|
||||||
|
track(DEVICE_GOAL_SUBMIT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitGoalCmd({ deviceId, goal });
|
await submitGoalCmd({ deviceId, goal });
|
||||||
@@ -105,6 +122,7 @@
|
|||||||
await stopGoalCmd({ deviceId });
|
await stopGoalCmd({ deviceId });
|
||||||
runStatus = 'failed';
|
runStatus = 'failed';
|
||||||
runError = 'Stopped by user';
|
runError = 'Stopped by user';
|
||||||
|
track(DEVICE_GOAL_STOP);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -147,6 +165,7 @@
|
|||||||
case 'goal_completed': {
|
case 'goal_completed': {
|
||||||
const success = msg.success as boolean;
|
const success = msg.success as boolean;
|
||||||
runStatus = success ? 'completed' : 'failed';
|
runStatus = success ? 'completed' : 'failed';
|
||||||
|
track(DEVICE_GOAL_COMPLETE, { success });
|
||||||
listDeviceSessions(deviceId).then((s) => {
|
listDeviceSessions(deviceId).then((s) => {
|
||||||
sessions = s as Session[];
|
sessions = s as Session[];
|
||||||
});
|
});
|
||||||
@@ -188,7 +207,12 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6 flex items-center gap-3">
|
<div class="mb-6 flex items-center gap-3">
|
||||||
<a href="/dashboard/devices" class="text-neutral-400 hover:text-neutral-600">←</a>
|
<a
|
||||||
|
href="/dashboard/devices"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:arrow-left-duotone" class="h-5 w-5" />
|
||||||
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold">{deviceData?.model ?? deviceId.slice(0, 8)}</h2>
|
<h2 class="text-2xl font-bold">{deviceData?.model ?? deviceId.slice(0, 8)}</h2>
|
||||||
{#if deviceData?.manufacturer}
|
{#if deviceData?.manufacturer}
|
||||||
@@ -196,7 +220,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="ml-2 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
|
class="ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||||
{deviceData?.status === 'online'
|
{deviceData?.status === 'online'
|
||||||
? 'bg-green-50 text-green-700'
|
? 'bg-green-50 text-green-700'
|
||||||
: 'bg-neutral-100 text-neutral-500'}"
|
: 'bg-neutral-100 text-neutral-500'}"
|
||||||
@@ -211,19 +235,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="mb-6 flex gap-1 rounded-lg bg-neutral-100 p-1">
|
<div class="mb-6 flex gap-1 rounded-xl bg-neutral-100 p-1">
|
||||||
{#each [['overview', 'Overview'], ['sessions', 'Sessions'], ['run', 'Run']] as [tab, label]}
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = tab as typeof activeTab)}
|
onclick={() => {
|
||||||
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
activeTab = tab.id;
|
||||||
{activeTab === tab
|
track(DEVICE_TAB_CHANGE, { tab: tab.id });
|
||||||
|
}}
|
||||||
|
class="flex flex-1 items-center justify-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors
|
||||||
|
{activeTab === tab.id
|
||||||
? 'bg-white text-neutral-900 shadow-sm'
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
: 'text-neutral-500 hover:text-neutral-700'}"
|
: 'text-neutral-500 hover:text-neutral-700'}"
|
||||||
>
|
>
|
||||||
{label}
|
<Icon
|
||||||
{#if tab === 'run' && runStatus === 'running'}
|
icon={tab.icon}
|
||||||
<span class="ml-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500"
|
class="h-4 w-4 {activeTab === tab.id ? 'text-neutral-700' : 'text-neutral-400'}"
|
||||||
></span>
|
/>
|
||||||
|
{tab.label}
|
||||||
|
{#if tab.id === 'run' && runStatus === 'running'}
|
||||||
|
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -234,11 +264,14 @@
|
|||||||
{#if activeTab === 'overview'}
|
{#if activeTab === 'overview'}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<!-- Device Specs -->
|
<!-- Device Specs -->
|
||||||
<div class="rounded-lg border border-neutral-200 p-5">
|
<div class="rounded-xl border border-neutral-200 p-5">
|
||||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<Icon icon="ph:device-mobile-duotone" class="h-4.5 w-4.5 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
Device Info
|
Device Info
|
||||||
</h3>
|
</h3>
|
||||||
<dl class="space-y-2">
|
</div>
|
||||||
|
<dl class="space-y-2.5">
|
||||||
{#if deviceData?.model}
|
{#if deviceData?.model}
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<dt class="text-neutral-500">Model</dt>
|
<dt class="text-neutral-500">Model</dt>
|
||||||
@@ -266,35 +299,51 @@
|
|||||||
{#if battery !== null && battery >= 0}
|
{#if battery !== null && battery >= 0}
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<dt class="text-neutral-500">Battery</dt>
|
<dt class="text-neutral-500">Battery</dt>
|
||||||
<dd class="font-medium {battery <= 20 ? 'text-red-600' : ''}">
|
<dd class="flex items-center gap-1.5 font-medium {battery <= 20 ? 'text-red-600' : ''}">
|
||||||
{battery}%{charging ? ' (Charging)' : ''}
|
<Icon
|
||||||
|
icon={charging ? 'ph:battery-charging-duotone' : battery > 50 ? 'ph:battery-high-duotone' : 'ph:battery-low-duotone'}
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{battery}%{charging ? ' Charging' : ''}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<dt class="text-neutral-500">Last seen</dt>
|
<dt class="text-neutral-500">Last seen</dt>
|
||||||
<dd class="font-medium">
|
<dd class="font-medium">
|
||||||
{deviceData ? relativeTime(deviceData.lastSeen) : '—'}
|
{deviceData ? relativeTime(deviceData.lastSeen) : '\u2014'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="rounded-lg border border-neutral-200 p-5">
|
<div class="rounded-xl border border-neutral-200 p-5">
|
||||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<Icon icon="ph:chart-bar-duotone" class="h-4.5 w-4.5 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
Stats
|
Stats
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-3 text-center">
|
<div class="grid grid-cols-3 gap-3 text-center">
|
||||||
<div>
|
<div class="rounded-lg bg-neutral-50 p-3">
|
||||||
|
<div class="mb-1 flex justify-center">
|
||||||
|
<Icon icon="ph:stack-duotone" class="h-5 w-5 text-neutral-400" />
|
||||||
|
</div>
|
||||||
<p class="text-2xl font-bold">{stats?.totalSessions ?? 0}</p>
|
<p class="text-2xl font-bold">{stats?.totalSessions ?? 0}</p>
|
||||||
<p class="text-xs text-neutral-500">Sessions</p>
|
<p class="text-xs text-neutral-500">Sessions</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="rounded-lg bg-neutral-50 p-3">
|
||||||
|
<div class="mb-1 flex justify-center">
|
||||||
|
<Icon icon="ph:chart-line-up-duotone" class="h-5 w-5 text-green-500" />
|
||||||
|
</div>
|
||||||
<p class="text-2xl font-bold">{stats?.successRate ?? 0}%</p>
|
<p class="text-2xl font-bold">{stats?.successRate ?? 0}%</p>
|
||||||
<p class="text-xs text-neutral-500">Success</p>
|
<p class="text-xs text-neutral-500">Success</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="rounded-lg bg-neutral-50 p-3">
|
||||||
|
<div class="mb-1 flex justify-center">
|
||||||
|
<Icon icon="ph:footprints-duotone" class="h-5 w-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
<p class="text-2xl font-bold">{stats?.avgSteps ?? 0}</p>
|
<p class="text-2xl font-bold">{stats?.avgSteps ?? 0}</p>
|
||||||
<p class="text-xs text-neutral-500">Avg Steps</p>
|
<p class="text-xs text-neutral-500">Avg Steps</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,22 +353,35 @@
|
|||||||
|
|
||||||
<!-- Installed Apps -->
|
<!-- Installed Apps -->
|
||||||
{#if deviceData && deviceData.installedApps.length > 0}
|
{#if deviceData && deviceData.installedApps.length > 0}
|
||||||
<div class="mt-4 rounded-lg border border-neutral-200">
|
<div class="mt-4 rounded-xl border border-neutral-200">
|
||||||
<div class="flex items-center justify-between border-b border-neutral-100 px-5 py-3">
|
<div class="flex items-center justify-between border-b border-neutral-100 px-5 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="ph:grid-four-duotone" class="h-4.5 w-4.5 text-neutral-400" />
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
Installed Apps
|
Installed Apps
|
||||||
<span class="ml-1 font-normal normal-case text-neutral-400">({deviceData.installedApps.length})</span>
|
<span class="ml-1 font-normal normal-case text-neutral-400"
|
||||||
|
>({deviceData.installedApps.length})</span
|
||||||
|
>
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<Icon
|
||||||
|
icon="ph:magnifying-glass-duotone"
|
||||||
|
class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={appSearch}
|
bind:value={appSearch}
|
||||||
placeholder="Search apps..."
|
placeholder="Search apps..."
|
||||||
class="w-48 rounded border border-neutral-200 px-2.5 py-1 text-xs focus:border-neutral-400 focus:outline-none"
|
class="w-48 rounded-lg border border-neutral-200 py-1 pl-8 pr-2.5 text-xs focus:border-neutral-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="max-h-72 overflow-y-auto">
|
<div class="max-h-72 overflow-y-auto">
|
||||||
{#each filteredApps as app (app.packageName)}
|
{#each filteredApps as app (app.packageName)}
|
||||||
<div class="flex items-center justify-between px-5 py-2 text-sm hover:bg-neutral-50">
|
<div
|
||||||
|
class="flex items-center justify-between px-5 py-2 text-sm hover:bg-neutral-50"
|
||||||
|
>
|
||||||
<span class="font-medium">{app.label}</span>
|
<span class="font-medium">{app.label}</span>
|
||||||
<span class="font-mono text-xs text-neutral-400">{app.packageName}</span>
|
<span class="font-mono text-xs text-neutral-400">{app.packageName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,9 +395,12 @@
|
|||||||
<!-- Sessions Tab -->
|
<!-- Sessions Tab -->
|
||||||
{:else if activeTab === 'sessions'}
|
{:else if activeTab === 'sessions'}
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="text-sm text-neutral-400">No sessions yet. Go to the Run tab to send a goal.</p>
|
<div class="rounded-xl border border-neutral-200 p-10 text-center">
|
||||||
|
<Icon icon="ph:clock-counter-clockwise-duotone" class="mx-auto mb-3 h-8 w-8 text-neutral-300" />
|
||||||
|
<p class="text-sm text-neutral-500">No sessions yet. Go to the Run tab to send a goal.</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
<div class="divide-y divide-neutral-100 rounded-xl border border-neutral-200">
|
||||||
{#each sessions as sess (sess.id)}
|
{#each sessions as sess (sess.id)}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -344,17 +409,27 @@
|
|||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-medium">{sess.goal}</p>
|
<p class="truncate text-sm font-medium">{sess.goal}</p>
|
||||||
<p class="mt-0.5 text-xs text-neutral-400">
|
<p class="mt-0.5 flex items-center gap-1.5 text-xs text-neutral-400">
|
||||||
|
<Icon icon="ph:clock-duotone" class="h-3.5 w-3.5" />
|
||||||
{formatTime(sess.startedAt)} · {sess.stepsUsed} steps
|
{formatTime(sess.startedAt)} · {sess.stepsUsed} steps
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="ml-3 shrink-0 rounded px-2 py-0.5 text-xs {sess.status === 'completed'
|
class="ml-3 flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium {sess.status ===
|
||||||
|
'completed'
|
||||||
? 'bg-green-50 text-green-700'
|
? 'bg-green-50 text-green-700'
|
||||||
: sess.status === 'running'
|
: sess.status === 'running'
|
||||||
? 'bg-amber-50 text-amber-700'
|
? 'bg-amber-50 text-amber-700'
|
||||||
: 'bg-red-50 text-red-700'}"
|
: 'bg-red-50 text-red-700'}"
|
||||||
>
|
>
|
||||||
|
<Icon
|
||||||
|
icon={sess.status === 'completed'
|
||||||
|
? 'ph:check-circle-duotone'
|
||||||
|
: sess.status === 'running'
|
||||||
|
? 'ph:circle-notch-duotone'
|
||||||
|
: 'ph:x-circle-duotone'}
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
{sess.status === 'completed'
|
{sess.status === 'completed'
|
||||||
? 'Success'
|
? 'Success'
|
||||||
: sess.status === 'running'
|
: sess.status === 'running'
|
||||||
@@ -399,29 +474,34 @@
|
|||||||
<!-- Run Tab -->
|
<!-- Run Tab -->
|
||||||
{:else if activeTab === 'run'}
|
{:else if activeTab === 'run'}
|
||||||
<!-- Goal Input -->
|
<!-- Goal Input -->
|
||||||
<div class="mb-6 rounded-lg border border-neutral-200 p-5">
|
<div class="mb-6 rounded-xl border border-neutral-200 p-5">
|
||||||
<h3 class="mb-3 text-sm font-semibold">Send a Goal</h3>
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<Icon icon="ph:target-duotone" class="h-4.5 w-4.5 text-neutral-500" />
|
||||||
|
<h3 class="text-sm font-semibold">Send a Goal</h3>
|
||||||
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={goal}
|
bind:value={goal}
|
||||||
placeholder="e.g., Open YouTube and search for lofi beats"
|
placeholder="e.g., Open YouTube and search for lofi beats"
|
||||||
class="flex-1 rounded border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
class="flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
disabled={runStatus === 'running'}
|
disabled={runStatus === 'running'}
|
||||||
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
||||||
/>
|
/>
|
||||||
{#if runStatus === 'running'}
|
{#if runStatus === 'running'}
|
||||||
<button
|
<button
|
||||||
onclick={stopGoal}
|
onclick={stopGoal}
|
||||||
class="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-500"
|
class="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500"
|
||||||
>
|
>
|
||||||
|
<Icon icon="ph:stop-duotone" class="h-4 w-4" />
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={submitGoal}
|
onclick={submitGoal}
|
||||||
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700"
|
class="flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
|
<Icon icon="ph:play-duotone" class="h-4 w-4" />
|
||||||
Run
|
Run
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -430,28 +510,36 @@
|
|||||||
|
|
||||||
<!-- Live Steps -->
|
<!-- Live Steps -->
|
||||||
{#if steps.length > 0 || runStatus !== 'idle'}
|
{#if steps.length > 0 || runStatus !== 'idle'}
|
||||||
<div class="rounded-lg border border-neutral-200">
|
<div class="rounded-xl border border-neutral-200">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-b border-neutral-200 px-5 py-3"
|
class="flex items-center justify-between border-b border-neutral-200 px-5 py-3"
|
||||||
>
|
>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Icon icon="ph:list-checks-duotone" class="h-4.5 w-4.5 text-neutral-400" />
|
||||||
{currentGoal ? `Goal: ${currentGoal}` : 'Current Run'}
|
{currentGoal ? `Goal: ${currentGoal}` : 'Current Run'}
|
||||||
</h3>
|
</h3>
|
||||||
{#if runStatus === 'running'}
|
{#if runStatus === 'running'}
|
||||||
<span class="flex items-center gap-1.5 text-xs text-amber-600">
|
<span class="flex items-center gap-1.5 text-xs font-medium text-amber-600">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500"
|
class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500"
|
||||||
></span>
|
></span>
|
||||||
Running
|
Running
|
||||||
</span>
|
</span>
|
||||||
{:else if runStatus === 'completed'}
|
{:else if runStatus === 'completed'}
|
||||||
<span class="text-xs text-green-600">Completed</span>
|
<span class="flex items-center gap-1.5 text-xs font-medium text-green-600">
|
||||||
|
<Icon icon="ph:check-circle-duotone" class="h-4 w-4" />
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
{:else if runStatus === 'failed'}
|
{:else if runStatus === 'failed'}
|
||||||
<span class="text-xs text-red-600">Failed</span>
|
<span class="flex items-center gap-1.5 text-xs font-medium text-red-600">
|
||||||
|
<Icon icon="ph:x-circle-duotone" class="h-4 w-4" />
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if runError}
|
{#if runError}
|
||||||
<div class="border-t border-red-100 bg-red-50 px-5 py-3 text-xs text-red-700">
|
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50 px-5 py-3 text-xs text-red-700">
|
||||||
|
<Icon icon="ph:warning-duotone" class="h-4 w-4 shrink-0" />
|
||||||
{runError}
|
{runError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -474,7 +562,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="px-5 py-3 text-xs text-neutral-400">Waiting for first step...</div>
|
<div class="flex items-center gap-2 px-5 py-3 text-xs text-neutral-400">
|
||||||
|
<Icon icon="ph:circle-notch-duotone" class="h-4 w-4 animate-spin" />
|
||||||
|
Waiting for first step...
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
|
import { track } from '$lib/analytics/track';
|
||||||
|
import { SETTINGS_SAVE } from '$lib/analytics/events';
|
||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const layoutData = page.data;
|
const layoutData = page.data;
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (updateConfig.result?.saved) {
|
if (updateConfig.result?.saved) {
|
||||||
toast.success('Settings saved');
|
toast.success('Settings saved');
|
||||||
|
track(SETTINGS_SAVE);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { login } from '$lib/api/auth.remote';
|
import { login } from '$lib/api/auth.remote';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { AUTH_LOGIN_SUBMIT, AUTH_SIGNUP_SUBMIT } from '$lib/analytics/events';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="auth">
|
<div class="flex min-h-screen items-center justify-center bg-neutral-50">
|
||||||
<h1>Log in</h1>
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900">
|
||||||
|
<Icon icon="ph:robot-duotone" class="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold">Log in to DroidClaw</h1>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">Welcome back</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form {...login}>
|
<form {...login} class="space-y-4">
|
||||||
<label>
|
<label class="block">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
|
||||||
|
<Icon icon="ph:envelope-duotone" class="h-4 w-4 text-neutral-400" />
|
||||||
Email
|
Email
|
||||||
<input {...login.fields.email.as('email')} />
|
</span>
|
||||||
|
<input
|
||||||
|
{...login.fields.email.as('email')}
|
||||||
|
class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
{#each login.fields.email.issues() ?? [] as issue (issue.message)}
|
{#each login.fields.email.issues() ?? [] as issue (issue.message)}
|
||||||
<p class="issue">{issue.message}</p>
|
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label class="block">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
|
||||||
|
<Icon icon="ph:lock-duotone" class="h-4 w-4 text-neutral-400" />
|
||||||
Password
|
Password
|
||||||
<input {...login.fields.password.as('password')} />
|
</span>
|
||||||
|
<input
|
||||||
|
{...login.fields.password.as('password')}
|
||||||
|
class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
{#each login.fields.password.issues() ?? [] as issue (issue.message)}
|
{#each login.fields.password.issues() ?? [] as issue (issue.message)}
|
||||||
<p class="issue">{issue.message}</p>
|
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p>
|
<button
|
||||||
Don't have an account?
|
type="submit"
|
||||||
<a href="/signup">Sign up</a>
|
data-umami-event={AUTH_LOGIN_SUBMIT}
|
||||||
</p>
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800"
|
||||||
|
>
|
||||||
<button type="submit">Login</button>
|
<Icon icon="ph:sign-in-duotone" class="h-4 w-4" />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-sm text-neutral-500">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="/signup" data-umami-event={AUTH_SIGNUP_SUBMIT} data-umami-event-source="login-page" class="font-medium text-neutral-700 hover:text-neutral-900">Sign up</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,75 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { signup } from '$lib/api/auth.remote';
|
import { signup } from '$lib/api/auth.remote';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { AUTH_LOGIN_SUBMIT, AUTH_SIGNUP_SUBMIT } from '$lib/analytics/events';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="auth">
|
<div class="flex min-h-screen items-center justify-center bg-neutral-50">
|
||||||
<h1>Sign up</h1>
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900">
|
||||||
|
<Icon icon="ph:robot-duotone" class="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold">Create your account</h1>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">Get started with DroidClaw</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form {...signup}>
|
<form {...signup} class="space-y-4">
|
||||||
<label>
|
<label class="block">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
|
||||||
|
<Icon icon="ph:user-duotone" class="h-4 w-4 text-neutral-400" />
|
||||||
Username
|
Username
|
||||||
<input {...signup.fields.name.as('text')} />
|
</span>
|
||||||
|
<input
|
||||||
|
{...signup.fields.name.as('text')}
|
||||||
|
class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
{#each signup.fields.name.issues() ?? [] as issue (issue.message)}
|
{#each signup.fields.name.issues() ?? [] as issue (issue.message)}
|
||||||
<p class="issue">{issue.message}</p>
|
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label class="block">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
|
||||||
|
<Icon icon="ph:envelope-duotone" class="h-4 w-4 text-neutral-400" />
|
||||||
Email
|
Email
|
||||||
<input {...signup.fields.email.as('text')} />
|
</span>
|
||||||
|
<input
|
||||||
|
{...signup.fields.email.as('text')}
|
||||||
|
class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
{#each signup.fields.email.issues() ?? [] as issue (issue.message)}
|
{#each signup.fields.email.issues() ?? [] as issue (issue.message)}
|
||||||
<p class="issue">{issue.message}</p>
|
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label class="block">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
|
||||||
|
<Icon icon="ph:lock-duotone" class="h-4 w-4 text-neutral-400" />
|
||||||
Password
|
Password
|
||||||
<input {...signup.fields.password.as('password')} />
|
</span>
|
||||||
|
<input
|
||||||
|
{...signup.fields.password.as('password')}
|
||||||
|
class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||||
|
/>
|
||||||
{#each signup.fields.password.issues() ?? [] as issue (issue.message)}
|
{#each signup.fields.password.issues() ?? [] as issue (issue.message)}
|
||||||
<p class="issue">{issue.message}</p>
|
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Sign up</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-umami-event={AUTH_SIGNUP_SUBMIT}
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:user-plus-duotone" class="h-4 w-4" />
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-sm text-neutral-500">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/login" data-umami-event={AUTH_LOGIN_SUBMIT} data-umami-event-source="signup-page" class="font-medium text-neutral-700 hover:text-neutral-900">Log in</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user