feat: comprehensive app improvements with Pinia state management

Backend:
- Add API keys management (create, list, delete endpoints)
- Add email verification flow (verify, resend verification)
- Add account management (profile, change password, delete account)
- Add billing/Stripe integration (checkout, portal, webhooks)
- Add GeoIP service for analytics
- Add bulk link creation and link restore endpoints
- Add QR code analytics endpoint
- Add project description field with migration
- Add QR code name and logo support with migration
- Improve QR code generator with logo overlay support
- Add rate limiting middleware
- Update tests for new functionality

Frontend:
- Refactor entire app to use Pinia for state management
- Add auth store with initialization, login, register, logout
- Add workspace store with CRUD for workspaces, projects, links,
  QR codes, domains, assets, and analytics
- Add localStorage persistence for workspace selection
- Update App.vue with proper store initialization
- Update AppLayout.vue to use store methods instead of direct API
- Refactor Projects.vue and Domains.vue to use store state/actions
- Add VerifyEmail.vue for email verification flow
- Add ForgotPassword.vue and ResetPassword.vue
- Add Settings.vue with profile, password, API keys, danger zone
- Add QRCodeDetail.vue for QR code analytics
- Add Billing.vue for subscription management
- Expand api/client.js with all new API methods
- Add workspace change watchers for automatic data refresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 18:53:03 -05:00
parent abf7968911
commit e7d96f5508
100 changed files with 11424 additions and 254 deletions

View File

@@ -1,3 +1,22 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useWorkspaceStore } from './stores/workspace';
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
onMounted(async () => {
// Initialize auth first
await authStore.initialize();
// If authenticated, initialize workspace
if (authStore.isAuthenticated) {
await workspaceStore.initialize();
}
});
</script>

View File

@@ -77,6 +77,38 @@ class ApiClient {
return this.request('POST', '/auth/login', { email, password });
}
forgotPassword(email) {
return this.request('POST', '/auth/forgot', { email });
}
resetPassword(token, newPassword) {
return this.request('POST', '/auth/reset', { token, newPassword });
}
getProfile() {
return this.request('GET', '/auth/profile');
}
updateProfile(data) {
return this.request('PUT', '/auth/profile', data);
}
changePassword(currentPassword, newPassword) {
return this.request('POST', '/auth/change-password', { currentPassword, newPassword });
}
resendVerification() {
return this.request('POST', '/auth/resend-verification');
}
verifyEmail(token) {
return this.request('POST', '/auth/verify-email', { token });
}
deleteAccount(password) {
return this.request('DELETE', '/auth/account', { password });
}
// Workspaces
listWorkspaces() {
return this.request('GET', '/workspaces');
@@ -126,6 +158,10 @@ class ApiClient {
return this.request('GET', path);
}
restoreLink(workspaceId, id) {
return this.request('POST', `/workspaces/${workspaceId}/links/${id}/restore`);
}
createLink(workspaceId, data) {
return this.request('POST', `/workspaces/${workspaceId}/links`, data);
}
@@ -142,8 +178,19 @@ class ApiClient {
return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`);
}
getLinkAnalytics(workspaceId, linkId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`);
bulkCreateLinks(workspaceId, links) {
return this.request('POST', `/workspaces/${workspaceId}/links/bulk`, { links });
}
getLinkAnalytics(workspaceId, linkId, period = '7d', startDate = null, endDate = null) {
const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?${params.toString()}`);
}
// QR Codes
@@ -175,9 +222,20 @@ class ApiClient {
return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`;
}
getQRCodeAnalytics(workspaceId, qrCodeId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${qrCodeId}/analytics?period=${period}`);
}
// Analytics
getWorkspaceAnalytics(workspaceId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`);
getWorkspaceAnalytics(workspaceId, period = '7d', startDate = null, endDate = null) {
const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/analytics?${params.toString()}`);
}
// Domains
@@ -209,6 +267,43 @@ class ApiClient {
deleteAsset(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`);
}
// Billing
createCheckoutSession(workspaceId, plan, successUrl, cancelUrl) {
return this.request('POST', '/billing/checkout', {
workspaceId,
plan,
successUrl,
cancelUrl,
});
}
createPortalSession(returnUrl) {
return this.request('POST', '/billing/portal', { returnUrl });
}
getSubscription(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/subscription`);
}
// Usage
getUsage(workspaceId = null) {
const path = workspaceId ? `/usage?workspaceId=${workspaceId}` : '/usage';
return this.request('GET', path);
}
// API Keys
listApiKeys(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/api-keys`);
}
createApiKey(workspaceId, name, expiresAt = null, scopes = null) {
return this.request('POST', `/workspaces/${workspaceId}/api-keys`, { name, expiresAt, scopes });
}
deleteApiKey(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/api-keys/${id}`);
}
}
export const api = new ApiClient();

View File

@@ -9,19 +9,141 @@
</div>
<div class="workspace-selector" v-if="workspaceStore.currentWorkspace">
<select
:value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange"
class="workspace-select"
>
<option
v-for="ws in workspaceStore.workspaces"
:key="ws.id"
:value="ws.id"
<div class="workspace-dropdown">
<select
:value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange"
class="workspace-select"
>
{{ ws.name }}
</option>
</select>
<option
v-for="ws in workspaceStore.workspaces"
:key="ws.id"
:value="ws.id"
>
{{ ws.name }}
</option>
</select>
<div class="workspace-actions">
<button class="ws-action-btn" @click="showCreateWorkspace = true" title="Create workspace">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="ws-action-btn" @click="showWorkspaceSettings = true" title="Workspace settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Create Workspace Modal -->
<div v-if="showCreateWorkspace" class="modal-overlay" @click.self="showCreateWorkspace = false">
<div class="modal">
<div class="modal-header">
<h2>Create Workspace</h2>
<button class="close-btn" @click="showCreateWorkspace = false">&times;</button>
</div>
<form @submit.prevent="createWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-name">Workspace Name</label>
<input
id="ws-name"
v-model="newWorkspaceName"
type="text"
placeholder="e.g., Marketing Team"
required
/>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateWorkspace = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingWs">
{{ creatingWs ? 'Creating...' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
<!-- Workspace Settings Modal -->
<div v-if="showWorkspaceSettings" class="modal-overlay" @click.self="showWorkspaceSettings = false">
<div class="modal">
<div class="modal-header">
<h2>Workspace Settings</h2>
<button class="close-btn" @click="showWorkspaceSettings = false">&times;</button>
</div>
<form @submit.prevent="updateWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-edit-name">Workspace Name</label>
<input
id="ws-edit-name"
v-model="editWorkspaceName"
type="text"
required
/>
</div>
<div class="workspace-info">
<div class="info-row">
<span class="info-label">Plan:</span>
<span class="info-value plan-badge" :class="workspaceStore.currentWorkspace?.plan?.toLowerCase()">
{{ workspaceStore.currentWorkspace?.plan }}
</span>
</div>
<div class="info-row">
<span class="info-label">Created:</span>
<span class="info-value">{{ formatDate(workspaceStore.currentWorkspace?.createdAt) }}</span>
</div>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions split">
<button
type="button"
class="btn btn-danger-outline"
@click="confirmDeleteWorkspace"
:disabled="workspaceStore.workspaces.length <= 1"
:title="workspaceStore.workspaces.length <= 1 ? 'Cannot delete your only workspace' : 'Delete workspace'"
>
Delete
</button>
<div class="action-group">
<button type="button" class="btn btn-secondary" @click="showWorkspaceSettings = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="updatingWs">
{{ updatingWs ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Delete Workspace Confirmation -->
<div v-if="showDeleteWorkspace" class="modal-overlay" @click.self="showDeleteWorkspace = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Workspace</h2>
<button class="close-btn" @click="showDeleteWorkspace = false">&times;</button>
</div>
<div class="modal-body">
<p class="delete-warning">
Are you sure you want to delete <strong>{{ workspaceStore.currentWorkspace?.name }}</strong>?
All links, QR codes, and analytics data will be permanently deleted.
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteWorkspace = false">Cancel</button>
<button class="btn btn-danger" @click="deleteWorkspace" :disabled="deletingWs">
{{ deletingWs ? 'Deleting...' : 'Delete Workspace' }}
</button>
</div>
</div>
</div>
<nav class="sidebar-nav">
@@ -59,6 +181,34 @@
</svg>
Analytics
</router-link>
<router-link to="/projects" class="nav-item" :class="{ active: $route.name === 'projects' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Projects
</router-link>
<router-link to="/domains" class="nav-item" :class="{ active: $route.name === 'domains' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Domains
</router-link>
<router-link to="/billing" class="nav-item" :class="{ active: $route.name === 'billing' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/>
</svg>
Billing
</router-link>
<router-link to="/settings" class="nav-item" :class="{ active: $route.name === 'settings' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</router-link>
</nav>
<div class="sidebar-footer">
@@ -80,7 +230,7 @@
</template>
<script setup>
import { onMounted } from 'vue';
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
@@ -89,11 +239,29 @@ const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
// Workspace management state
const showCreateWorkspace = ref(false);
const showWorkspaceSettings = ref(false);
const showDeleteWorkspace = ref(false);
const newWorkspaceName = ref('');
const editWorkspaceName = ref('');
const wsError = ref('');
const creatingWs = ref(false);
const updatingWs = ref(false);
const deletingWs = ref(false);
onMounted(async () => {
authStore.checkAuth();
await workspaceStore.fetchWorkspaces();
// Ensure stores are initialized (in case component mounts before App.vue init completes)
await authStore.initialize();
await workspaceStore.initialize();
});
watch(() => workspaceStore.currentWorkspace, (ws) => {
if (ws) {
editWorkspaceName.value = ws.name;
}
}, { immediate: true });
const onWorkspaceChange = (e) => {
const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value);
if (workspace) {
@@ -101,7 +269,65 @@ const onWorkspaceChange = (e) => {
}
};
const createWorkspace = async () => {
creatingWs.value = true;
wsError.value = '';
try {
const workspace = await workspaceStore.createWorkspace(newWorkspaceName.value);
workspaceStore.setCurrentWorkspace(workspace);
showCreateWorkspace.value = false;
newWorkspaceName.value = '';
} catch (err) {
wsError.value = err.message;
} finally {
creatingWs.value = false;
}
};
const updateWorkspace = async () => {
updatingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.updateWorkspace(wsId, editWorkspaceName.value);
showWorkspaceSettings.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
updatingWs.value = false;
}
};
const confirmDeleteWorkspace = () => {
showWorkspaceSettings.value = false;
showDeleteWorkspace.value = true;
};
const deleteWorkspace = async () => {
deletingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.deleteWorkspace(wsId);
showDeleteWorkspace.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
deletingWs.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const logout = () => {
workspaceStore.clearAll();
authStore.logout();
router.push('/login');
};
@@ -227,4 +453,260 @@ const logout = () => {
padding: 32px;
overflow-y: auto;
}
/* Workspace dropdown */
.workspace-dropdown {
display: flex;
gap: 8px;
}
.workspace-dropdown .workspace-select {
flex: 1;
}
.workspace-actions {
display: flex;
gap: 4px;
}
.ws-action-btn {
width: 36px;
height: 36px;
border: 1px solid var(--line);
background: var(--bg);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
transition: all 0.15s;
}
.ws-action-btn:hover {
background: var(--surface);
color: var(--ink);
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 380px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.workspace-info {
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 0.875rem;
color: #6b7280;
}
.info-value {
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions.split {
justify-content: space-between;
}
.action-group {
display: flex;
gap: 0.75rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
line-height: 1.5;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-top: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger-outline {
background: white;
color: #dc2626;
border: 1px solid #fecaca;
}
.btn-danger-outline:hover:not(:disabled) {
background: #fef2f2;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -5,12 +5,20 @@ import { useAuthStore } from '../stores/auth';
import Landing from '../views/Landing.vue';
import Login from '../views/auth/Login.vue';
import Register from '../views/auth/Register.vue';
import ForgotPassword from '../views/auth/ForgotPassword.vue';
import ResetPassword from '../views/auth/ResetPassword.vue';
import VerifyEmail from '../views/auth/VerifyEmail.vue';
import Dashboard from '../views/dashboard/Dashboard.vue';
import Links from '../views/links/Links.vue';
import LinkDetail from '../views/links/LinkDetail.vue';
import QRCodes from '../views/qrcodes/QRCodes.vue';
import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue';
import QRCodeDetail from '../views/qrcodes/QRCodeDetail.vue';
import Analytics from '../views/analytics/Analytics.vue';
import Billing from '../views/billing/Billing.vue';
import Projects from '../views/projects/Projects.vue';
import Domains from '../views/domains/Domains.vue';
import Settings from '../views/settings/Settings.vue';
const routes = [
{
@@ -30,6 +38,23 @@ const routes = [
component: Register,
meta: { guest: true },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: ForgotPassword,
meta: { guest: true },
},
{
path: '/reset-password',
name: 'reset-password',
component: ResetPassword,
meta: { guest: true },
},
{
path: '/verify-email',
name: 'verify-email',
component: VerifyEmail,
},
{
path: '/dashboard',
name: 'dashboard',
@@ -66,12 +91,42 @@ const routes = [
component: QRCodeDesigner,
meta: { requiresAuth: true },
},
{
path: '/qrcodes/:id/analytics',
name: 'qrcode-analytics',
component: QRCodeDetail,
meta: { requiresAuth: true },
},
{
path: '/analytics',
name: 'analytics',
component: Analytics,
meta: { requiresAuth: true },
},
{
path: '/billing',
name: 'billing',
component: Billing,
meta: { requiresAuth: true },
},
{
path: '/projects',
name: 'projects',
component: Projects,
meta: { requiresAuth: true },
},
{
path: '/domains',
name: 'domains',
component: Domains,
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: Settings,
meta: { requiresAuth: true },
},
];
const router = createRouter({

View File

@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
token: localStorage.getItem('token'),
loading: false,
error: null,
initialized: false,
}),
getters: {
@@ -14,13 +15,30 @@ export const useAuthStore = defineStore('auth', {
},
actions: {
async initialize() {
if (this.initialized) return;
if (this.token) {
api.setToken(this.token);
try {
const profile = await api.getProfile();
this.user = profile;
} catch (err) {
// Token is invalid, clear it
this.logout();
}
}
this.initialized = true;
},
async register(email, password) {
this.loading = true;
this.error = null;
try {
const response = await api.register(email, password);
this.token = response.token;
this.user = { email: response.email };
this.user = { email: response.email, isVerified: false };
localStorage.setItem('token', response.token);
api.setToken(response.token);
return true;
} catch (err) {
@@ -38,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
const response = await api.login(email, password);
this.token = response.token;
this.user = { email: response.email };
localStorage.setItem('token', response.token);
api.setToken(response.token);
return true;
} catch (err) {
@@ -51,15 +70,17 @@ export const useAuthStore = defineStore('auth', {
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('currentWorkspaceId');
api.setToken(null);
},
checkAuth() {
if (this.token) {
api.setToken(this.token);
return true;
async fetchProfile() {
try {
this.user = await api.getProfile();
} catch (err) {
this.error = err.message;
}
return false;
},
},
});

View File

@@ -8,23 +8,44 @@ export const useWorkspaceStore = defineStore('workspace', {
projects: [],
links: [],
qrcodes: [],
domains: [],
assets: [],
analytics: null,
loading: false,
error: null,
initialized: false,
}),
getters: {
currentWorkspaceId: (state) => state.currentWorkspace?.id,
currentPlan: (state) => state.currentWorkspace?.plan || 'Free',
canUseCustomDomains: (state) => {
const plan = state.currentWorkspace?.plan;
return plan === 'Pro' || plan === 'Business';
},
},
actions: {
async initialize() {
if (this.initialized) return;
await this.fetchWorkspaces();
this.initialized = true;
},
async fetchWorkspaces() {
this.loading = true;
try {
const response = await api.listWorkspaces();
this.workspaces = response.workspaces;
if (!this.currentWorkspace && this.workspaces.length > 0) {
this.currentWorkspace = this.workspaces[0];
this.workspaces = response.workspaces || [];
// Restore saved workspace or use first one
const savedId = localStorage.getItem('currentWorkspaceId');
const saved = savedId ? this.workspaces.find(w => w.id === savedId) : null;
if (saved) {
this.currentWorkspace = saved;
} else if (this.workspaces.length > 0) {
this.setCurrentWorkspace(this.workspaces[0]);
}
} catch (err) {
this.error = err.message;
@@ -35,9 +56,17 @@ export const useWorkspaceStore = defineStore('workspace', {
setCurrentWorkspace(workspace) {
this.currentWorkspace = workspace;
if (workspace) {
localStorage.setItem('currentWorkspaceId', workspace.id);
} else {
localStorage.removeItem('currentWorkspaceId');
}
// Clear workspace-specific data
this.projects = [];
this.links = [];
this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null;
},
@@ -52,12 +81,42 @@ export const useWorkspaceStore = defineStore('workspace', {
}
},
async updateWorkspace(id, name) {
try {
const updated = await api.updateWorkspace(id, name);
const index = this.workspaces.findIndex(w => w.id === id);
if (index !== -1) {
this.workspaces[index] = updated;
}
if (this.currentWorkspace?.id === id) {
this.currentWorkspace = updated;
}
return updated;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteWorkspace(id) {
try {
await api.deleteWorkspace(id);
this.workspaces = this.workspaces.filter(w => w.id !== id);
if (this.currentWorkspace?.id === id) {
this.setCurrentWorkspace(this.workspaces[0] || null);
}
} catch (err) {
this.error = err.message;
throw err;
}
},
// Projects
async fetchProjects() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listProjects(this.currentWorkspaceId);
this.projects = response.projects;
this.projects = response.projects || [];
} catch (err) {
this.error = err.message;
}
@@ -67,7 +126,22 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const project = await api.createProject(this.currentWorkspaceId, name, description);
this.projects.push(project);
this.projects.unshift(project);
return project;
} catch (err) {
this.error = err.message;
throw err;
}
},
async updateProject(id, data) {
if (!this.currentWorkspaceId) return;
try {
const project = await api.updateProject(this.currentWorkspaceId, id, data);
const index = this.projects.findIndex(p => p.id === id);
if (index !== -1) {
this.projects[index] = project;
}
return project;
} catch (err) {
this.error = err.message;
@@ -91,7 +165,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listLinks(this.currentWorkspaceId, params);
this.links = response.links;
this.links = response.links || [];
} catch (err) {
this.error = err.message;
}
@@ -140,7 +214,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listQRCodes(this.currentWorkspaceId);
this.qrcodes = response.qrCodes;
this.qrcodes = response.qrCodes || [];
} catch (err) {
this.error = err.message;
}
@@ -184,14 +258,109 @@ export const useWorkspaceStore = defineStore('workspace', {
}
},
// Analytics
async fetchAnalytics(period = '7d') {
// Domains
async fetchDomains() {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period);
const response = await api.listDomains(this.currentWorkspaceId);
this.domains = response.domains || [];
} catch (err) {
this.error = err.message;
}
},
async addDomain(hostname) {
if (!this.currentWorkspaceId) return;
try {
const domain = await api.addDomain(this.currentWorkspaceId, hostname);
this.domains.unshift(domain);
return domain;
} catch (err) {
this.error = err.message;
throw err;
}
},
async verifyDomain(id) {
if (!this.currentWorkspaceId) return;
try {
const result = await api.verifyDomain(this.currentWorkspaceId, id);
// Refresh domains to get updated status
await this.fetchDomains();
return result;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteDomain(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteDomain(this.currentWorkspaceId, id);
this.domains = this.domains.filter(d => d.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Assets
async fetchAssets() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listAssets(this.currentWorkspaceId);
this.assets = response.assets || [];
} catch (err) {
this.error = err.message;
}
},
async uploadAsset(file) {
if (!this.currentWorkspaceId) return;
try {
const asset = await api.uploadAsset(this.currentWorkspaceId, file);
this.assets.unshift(asset);
return asset;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteAsset(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteAsset(this.currentWorkspaceId, id);
this.assets = this.assets.filter(a => a.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Analytics
async fetchAnalytics(period = '7d', startDate = null, endDate = null) {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period, startDate, endDate);
} catch (err) {
this.error = err.message;
}
},
// Clear all data (for logout)
clearAll() {
this.workspaces = [];
this.currentWorkspace = null;
this.projects = [];
this.links = [];
this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null;
this.initialized = false;
localStorage.removeItem('currentWorkspaceId');
},
},
});

View File

@@ -6,15 +6,28 @@
<h1>Analytics</h1>
<p class="subtitle">Track performance across your workspace</p>
</div>
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
<div class="period-controls">
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value && !isCustomRange }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
<button
:class="{ active: isCustomRange }"
@click="toggleCustomRange"
>
Custom
</button>
</div>
<div v-if="isCustomRange" class="date-range">
<input type="date" v-model="startDate" @change="applyCustomRange" />
<span>to</span>
<input type="date" v-model="endDate" @change="applyCustomRange" />
</div>
</div>
</header>
@@ -198,13 +211,34 @@ const periods = [
];
const period = ref('7d');
const isCustomRange = ref(false);
const startDate = ref('');
const endDate = ref('');
const analytics = computed(() => workspaceStore.analytics);
const setPeriod = async (p) => {
isCustomRange.value = false;
period.value = p;
await workspaceStore.fetchAnalytics(p);
};
const toggleCustomRange = () => {
isCustomRange.value = true;
// Set default range to last 30 days
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
startDate.value = start.toISOString().split('T')[0];
endDate.value = end.toISOString().split('T')[0];
applyCustomRange();
};
const applyCustomRange = async () => {
if (startDate.value && endDate.value) {
await workspaceStore.fetchAnalytics(null, startDate.value, endDate.value);
}
};
const maxEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1);
@@ -264,6 +298,13 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted);
}
.period-controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.period-selector {
display: flex;
gap: 4px;
@@ -290,6 +331,36 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: white;
}
.date-range {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface);
padding: 8px 12px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.date-range input[type="date"] {
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--bg);
color: var(--ink);
}
.date-range input[type="date"]:focus {
outline: none;
border-color: var(--accent);
}
.date-range span {
color: var(--muted);
font-size: 0.875rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);

View File

@@ -0,0 +1,215 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Reset your password</h1>
<p>Enter your email and we'll send you a reset link</p>
</div>
<form v-if="!submitted" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading">
{{ loading ? 'Sending...' : 'Send reset link' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Check your email</h2>
<p>If an account exists for {{ email }}, we've sent password reset instructions.</p>
<router-link to="/login" class="cta full">Back to login</router-link>
</div>
<p class="auth-footer" v-if="!submitted">
Remember your password?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { api } from '../../api/client';
const email = ref('');
const loading = ref(false);
const error = ref('');
const submitted = ref(false);
const handleSubmit = async () => {
loading.value = true;
error.value = '';
try {
await api.forgotPassword(email.value);
submitted.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
</style>

View File

@@ -37,6 +37,10 @@
{{ authStore.error }}
</div>
<div class="form-actions">
<router-link to="/forgot-password" class="forgot-link">Forgot password?</router-link>
</div>
<button type="submit" class="cta full" :disabled="authStore.loading">
{{ authStore.loading ? 'Signing in...' : 'Sign in' }}
</button>
@@ -167,6 +171,19 @@ const handleSubmit = async () => {
font-size: 0.9rem;
}
.form-actions {
text-align: right;
}
.forgot-link {
color: var(--muted);
font-size: 0.9rem;
}
.forgot-link:hover {
color: var(--accent);
}
.cta.full {
width: 100%;
padding: 14px;

View File

@@ -0,0 +1,231 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Set new password</h1>
<p>Enter your new password below</p>
</div>
<form v-if="!success" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="password">New Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
minlength="8"
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading || !isValid">
{{ loading ? 'Resetting...' : 'Reset password' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Password reset complete</h2>
<p>Your password has been updated. You can now sign in with your new password.</p>
<router-link to="/login" class="cta full">Sign in</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const password = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const error = ref('');
const success = ref(false);
const token = computed(() => route.query.token || '');
const isValid = computed(() => {
return password.value.length >= 8 && password.value === confirmPassword.value;
});
const handleSubmit = async () => {
if (!token.value) {
error.value = 'Invalid reset link. Please request a new one.';
return;
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match';
return;
}
loading.value = true;
error.value = '';
try {
await api.resetPassword(token.value, password.value);
success.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="status-message loading">
<div class="spinner"></div>
<p>Verifying your email...</p>
</div>
<!-- Success state -->
<div v-else-if="success" class="status-message success">
<div class="status-icon success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Email verified!</h2>
<p>Your email has been verified successfully. You can now access all features.</p>
<router-link to="/dashboard" class="cta full">Go to Dashboard</router-link>
</div>
<!-- Error state -->
<div v-else-if="error" class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<h2>Verification failed</h2>
<p>{{ error }}</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
<!-- No token state -->
<div v-else class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h2>Invalid link</h2>
<p>This verification link is invalid or missing. Please check your email for the correct link.</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const loading = ref(false);
const success = ref(false);
const error = ref('');
const token = computed(() => route.query.token || '');
const title = computed(() => {
if (loading.value) return 'Verifying email';
if (success.value) return 'Email verified';
if (error.value || !token.value) return 'Verification failed';
return 'Verify your email';
});
const subtitle = computed(() => {
if (loading.value) return 'Please wait while we verify your email address';
if (success.value) return 'Your account is now fully activated';
return 'There was a problem with your verification';
});
onMounted(async () => {
if (!token.value) {
return;
}
loading.value = true;
try {
await api.verifyEmail(token.value);
success.value = true;
} catch (err) {
error.value = err.message || 'Unable to verify your email. The link may be invalid or expired.';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.status-message {
text-align: center;
}
.status-message.loading {
padding: 20px 0;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-message.loading p {
color: var(--muted);
}
.status-icon {
margin-bottom: 16px;
}
.success-icon {
color: #22c55e;
}
.error-icon {
color: #dc2626;
}
.status-message h2 {
font-size: 1.25rem;
margin-bottom: 12px;
}
.status-message p {
color: var(--muted);
margin-bottom: 24px;
line-height: 1.5;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.link-secondary {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.link-secondary:hover {
color: var(--ink);
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,598 @@
<template>
<AppLayout>
<div class="billing">
<header class="page-header">
<h1>Billing & Plans</h1>
<p class="subtitle">Manage your subscription and view usage</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Current Plan Card -->
<section class="card current-plan">
<div class="plan-header">
<div>
<h2>Current Plan</h2>
<div class="plan-badge" :class="currentPlan.toLowerCase()">
{{ currentPlan }}
</div>
</div>
<button
v-if="subscription?.subscriptionId"
class="btn btn-secondary"
@click="openPortal"
:disabled="portalLoading"
>
{{ portalLoading ? 'Loading...' : 'Manage Subscription' }}
</button>
</div>
<div v-if="subscription?.cancelAtPeriodEnd" class="cancel-notice">
Your subscription will be canceled on {{ formatDate(subscription.currentPeriodEnd) }}.
You'll be downgraded to the Free plan after this date.
</div>
<div class="usage-section" v-if="usage">
<h3>Usage This Month</h3>
<div class="usage-grid">
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) + '%' }"
:class="{ warning: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Workspaces</span>
<span>{{ usage.workspaces }} / {{ formatLimit(usage.limits.maxWorkspaces) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.links, usage.limits.maxLinks) + '%' }"
:class="{ warning: getUsagePercent(usage.links, usage.limits.maxLinks) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Links</span>
<span>{{ usage.links }} / {{ formatLimit(usage.limits.maxLinks) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) + '%' }"
:class="{ warning: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) > 80 }"
></div>
</div>
<div class="usage-label">
<span>QR Codes</span>
<span>{{ usage.qrCodes }} / {{ formatLimit(usage.limits.maxQRCodes) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) + '%' }"
:class="{ warning: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Events</span>
<span>{{ formatNumber(usage.eventsThisMonth) }} / {{ formatLimit(usage.limits.maxEventsPerMonth) }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- Plan Comparison -->
<section class="plans-section">
<h2>Available Plans</h2>
<div class="plans-grid">
<div class="plan-card" :class="{ current: currentPlan === 'Free' }">
<div class="plan-name">Free</div>
<div class="plan-price">
<span class="amount">$0</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>1 workspace</li>
<li>50 links</li>
<li>25 QR codes</li>
<li>10,000 events/month</li>
<li>Basic analytics</li>
</ul>
<button
v-if="currentPlan === 'Free'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
</div>
<div class="plan-card featured" :class="{ current: currentPlan === 'Pro' }">
<div class="popular-badge">Most Popular</div>
<div class="plan-name">Pro</div>
<div class="plan-price">
<span class="amount">$29</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>5 workspaces</li>
<li>5,000 links</li>
<li>1,000 QR codes</li>
<li>100,000 events/month</li>
<li>3 custom domains</li>
<li>Password protection</li>
<li>Advanced analytics</li>
</ul>
<button
v-if="currentPlan === 'Pro'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else-if="currentPlan === 'Free'"
class="btn btn-primary"
@click="upgrade('Pro')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Pro' }}
</button>
<button
v-else
class="btn btn-secondary"
@click="openPortal"
>
Downgrade
</button>
</div>
<div class="plan-card" :class="{ current: currentPlan === 'Business' }">
<div class="plan-name">Business</div>
<div class="plan-price">
<span class="amount">$99</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>Unlimited workspaces</li>
<li>Unlimited links</li>
<li>Unlimited QR codes</li>
<li>Unlimited events</li>
<li>Unlimited custom domains</li>
<li>Password protection</li>
<li>Priority support</li>
<li>API access</li>
</ul>
<button
v-if="currentPlan === 'Business'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else
class="btn btn-primary"
@click="upgrade('Business')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Business' }}
</button>
</div>
</div>
</section>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const upgrading = ref(false);
const portalLoading = ref(false);
const error = ref('');
const usage = ref(null);
const subscription = ref(null);
const currentPlan = computed(() => usage.value?.plan || 'Free');
onMounted(async () => {
// Check for success from checkout
if (route.query.session_id) {
// Payment was successful, refresh data
setTimeout(() => loadData(), 1000);
} else {
loadData();
}
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
const [usageData, subData] = await Promise.all([
api.getUsage(workspaceId),
workspaceId ? api.getSubscription(workspaceId) : Promise.resolve(null),
]);
usage.value = usageData;
subscription.value = subData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function upgrade(plan) {
upgrading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) {
throw new Error('No workspace selected');
}
const successUrl = window.location.origin + '/billing';
const cancelUrl = window.location.origin + '/billing';
const { url } = await api.createCheckoutSession(
workspaceId,
plan,
successUrl,
cancelUrl
);
window.location.href = url;
} catch (err) {
error.value = err.message;
upgrading.value = false;
}
}
async function openPortal() {
portalLoading.value = true;
error.value = '';
try {
const returnUrl = window.location.origin + '/billing';
const { url } = await api.createPortalSession(returnUrl);
window.location.href = url;
} catch (err) {
error.value = err.message;
portalLoading.value = false;
}
}
function getUsagePercent(used, limit) {
if (limit === 2147483647 || limit === -1) return 0; // Unlimited
return Math.min(100, (used / limit) * 100);
}
function formatLimit(limit) {
if (limit === 2147483647 || limit === -1) return 'Unlimited';
return formatNumber(limit);
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<style scoped>
.billing {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.current-plan .plan-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.current-plan h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.plan-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.cancel-notice {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
color: #92400e;
}
.usage-section h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
color: #374151;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.usage-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.usage-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: #3b82f6;
border-radius: 4px;
transition: width 0.3s ease;
}
.usage-fill.warning {
background: #f59e0b;
}
.usage-label {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #6b7280;
}
.plans-section {
margin-top: 2rem;
}
.plans-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1.5rem;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.plan-card {
background: white;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
position: relative;
transition: border-color 0.2s, box-shadow 0.2s;
}
.plan-card:hover {
border-color: #d1d5db;
}
.plan-card.featured {
border-color: #3b82f6;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
}
.plan-card.current {
background: #f9fafb;
}
.popular-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
.plan-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.plan-price {
margin-bottom: 1.5rem;
}
.plan-price .amount {
font-size: 2rem;
font-weight: 700;
}
.plan-price .period {
color: #6b7280;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
}
.plan-features li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: #374151;
}
.plan-features li::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2310b981'%3E%3Cpath fill-rule='evenodd' d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' clip-rule='evenodd'/%3E%3C/svg%3E") no-repeat center;
}
.btn {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.billing {
padding: 1rem;
}
.current-plan .plan-header {
flex-direction: column;
gap: 1rem;
}
.plans-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -145,6 +145,26 @@
<p>No referrer data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Top Countries</h2>
</div>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown.slice(0, 5)" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
<p class="hint">Country detection requires a GeoIP database</p>
</div>
</section>
</div>
</div>
</AppLayout>
@@ -189,6 +209,52 @@ const getPercentage = (value) => {
return Math.max((value / totalEvents.value) * 100, 5);
};
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping (common codes)
const countryNames = {
US: 'United States',
GB: 'United Kingdom',
CA: 'Canada',
AU: 'Australia',
DE: 'Germany',
FR: 'France',
JP: 'Japan',
CN: 'China',
IN: 'India',
BR: 'Brazil',
MX: 'Mexico',
ES: 'Spain',
IT: 'Italy',
NL: 'Netherlands',
SE: 'Sweden',
NO: 'Norway',
DK: 'Denmark',
FI: 'Finland',
PL: 'Poland',
RU: 'Russia',
KR: 'South Korea',
SG: 'Singapore',
NZ: 'New Zealand',
IE: 'Ireland',
CH: 'Switzerland',
AT: 'Austria',
BE: 'Belgium',
PT: 'Portugal',
};
const getCountryName = (countryCode) => {
return countryNames[countryCode] || countryCode;
};
onMounted(async () => {
await workspaceStore.fetchLinks();
await workspaceStore.fetchAnalytics(period.value);
@@ -417,10 +483,24 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted);
}
.empty-state .hint {
font-size: 0.85rem;
margin-top: 8px;
}
.empty-state .cta {
margin-top: 16px;
}
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.cta.small {
padding: 10px 16px;
font-size: 0.9rem;

View File

@@ -0,0 +1,761 @@
<template>
<AppLayout>
<div class="domains">
<header class="page-header">
<div>
<h1>Custom Domains</h1>
<p class="subtitle">Use your own domain for branded short links</p>
</div>
<button class="btn btn-primary" @click="showAddModal = true" :disabled="!canAddDomain">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Domain
</button>
</header>
<div v-if="!canAddDomain" class="upgrade-banner">
<div class="banner-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<div>
<strong>Custom domains require a Pro or Business plan.</strong>
<p>Upgrade to use your own branded domains for short links.</p>
</div>
</div>
<router-link to="/billing" class="btn btn-primary">Upgrade</router-link>
</div>
<div v-if="loading" class="loading">Loading domains...</div>
<div v-else-if="domains.length === 0 && canAddDomain" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3>No custom domains yet</h3>
<p>Add your own domain to create branded short links.</p>
<button class="btn btn-primary" @click="showAddModal = true">Add Domain</button>
</div>
<div v-else-if="domains.length > 0" class="domains-list">
<div v-for="domain in domains" :key="domain.id" class="domain-card">
<div class="domain-info">
<div class="domain-status" :class="domain.status.toLowerCase()">
{{ domain.status }}
</div>
<h3 class="domain-name">{{ domain.hostname }}</h3>
<p class="domain-date">Added {{ formatDate(domain.createdAt) }}</p>
</div>
<div class="domain-actions">
<template v-if="domain.status === 'Pending'">
<button class="btn btn-secondary" @click="showVerifyInstructions(domain)">
Verify Domain
</button>
</template>
<button class="icon-btn danger" @click="confirmDelete(domain)" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Add Domain Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<div class="modal-header">
<h2>Add Custom Domain</h2>
<button class="close-btn" @click="showAddModal = false">&times;</button>
</div>
<form @submit.prevent="addDomain">
<div class="form-group">
<label for="hostname">Domain</label>
<input
id="hostname"
v-model="newDomain"
type="text"
placeholder="links.yourdomain.com"
required
autofocus
/>
<p class="form-hint">Enter the subdomain or domain you want to use for short links.</p>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="adding">
{{ adding ? 'Adding...' : 'Add Domain' }}
</button>
</div>
</form>
</div>
</div>
<!-- Verification Instructions Modal -->
<div v-if="showVerifyModal" class="modal-overlay" @click.self="showVerifyModal = false">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Verify {{ verifyingDomain?.hostname }}</h2>
<button class="close-btn" @click="showVerifyModal = false">&times;</button>
</div>
<div class="verify-content">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Add DNS TXT Record</h4>
<p>Add the following TXT record to your domain's DNS settings:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">TXT</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">_trakqr-verification</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<div class="record-value token">
{{ verifyingDomain?.verificationToken }}
<button class="copy-btn" @click="copyToken" title="Copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Add CNAME Record</h4>
<p>Point your domain to our servers:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">CNAME</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">{{ verifyingDomain?.hostname }}</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<span class="record-value">cname.trakqr.com</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Verify Ownership</h4>
<p>DNS changes can take up to 48 hours to propagate. Once ready, click verify.</p>
</div>
</div>
<div v-if="verifyError" class="error-message">{{ verifyError }}</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showVerifyModal = false">Close</button>
<button class="btn btn-primary" @click="verifyDomain" :disabled="verifying">
{{ verifying ? 'Verifying...' : 'Verify Domain' }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Domain</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ domainToDelete?.hostname }}</strong>?
Links using this domain will stop working.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteDomain" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const domains = computed(() => workspaceStore.domains);
const loading = ref(true);
const adding = ref(false);
const verifying = ref(false);
const deleting = ref(false);
const error = ref('');
const verifyError = ref('');
const showAddModal = ref(false);
const showVerifyModal = ref(false);
const showDeleteModal = ref(false);
const newDomain = ref('');
const verifyingDomain = ref(null);
const domainToDelete = ref(null);
const canAddDomain = computed(() => workspaceStore.canUseCustomDomains);
onMounted(async () => {
await loadDomains();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadDomains();
}
});
async function loadDomains() {
loading.value = true;
try {
await workspaceStore.fetchDomains();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function addDomain() {
adding.value = true;
error.value = '';
try {
const domain = await workspaceStore.addDomain(newDomain.value);
showAddModal.value = false;
newDomain.value = '';
// Show verification instructions for the new domain
verifyingDomain.value = domain;
showVerifyModal.value = true;
} catch (err) {
error.value = err.message;
} finally {
adding.value = false;
}
}
function showVerifyInstructions(domain) {
verifyingDomain.value = domain;
verifyError.value = '';
showVerifyModal.value = true;
}
async function verifyDomain() {
verifying.value = true;
verifyError.value = '';
try {
await workspaceStore.verifyDomain(verifyingDomain.value.id);
showVerifyModal.value = false;
verifyingDomain.value = null;
} catch (err) {
verifyError.value = err.message;
} finally {
verifying.value = false;
}
}
function confirmDelete(domain) {
domainToDelete.value = domain;
showDeleteModal.value = true;
}
async function deleteDomain() {
deleting.value = true;
try {
await workspaceStore.deleteDomain(domainToDelete.value.id);
showDeleteModal.value = false;
domainToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function copyToken() {
navigator.clipboard.writeText(verifyingDomain.value?.verificationToken);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.domains {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.banner-content {
display: flex;
align-items: flex-start;
gap: 1rem;
color: #92400e;
}
.banner-content svg {
flex-shrink: 0;
margin-top: 2px;
}
.banner-content strong {
display: block;
margin-bottom: 0.25rem;
}
.banner-content p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.domains-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.domain-card {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.domain-status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.domain-status.pending {
background: #fef3c7;
color: #92400e;
}
.domain-status.verified {
background: #d1fae5;
color: #065f46;
}
.domain-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.domain-date {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.domain-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-lg {
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.verify-content {
padding: 1.5rem;
}
.step {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.step:last-of-type {
margin-bottom: 0;
}
.step-number {
width: 28px;
height: 28px;
background: #3b82f6;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
}
.step-content h4 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.step-content p {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1rem;
}
.dns-record {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
}
.record-row {
display: flex;
margin-bottom: 0.5rem;
}
.record-row:last-child {
margin-bottom: 0;
}
.record-label {
width: 100px;
font-size: 0.75rem;
color: #6b7280;
flex-shrink: 0;
}
.record-value {
font-size: 0.875rem;
font-family: monospace;
word-break: break-all;
}
.record-value.token {
display: flex;
align-items: center;
gap: 0.5rem;
}
.copy-btn {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #6b7280;
border-radius: 4px;
}
.copy-btn:hover {
background: #e5e7eb;
color: #374151;
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin: 1rem 0 0;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.upgrade-banner {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.banner-content {
flex-direction: column;
align-items: center;
}
.domain-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
</style>

View File

@@ -97,6 +97,23 @@
<p>No referrer data yet</p>
</div>
</section>
<section class="card">
<h2>Countries</h2>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
</div>
</section>
</div>
<section class="card link-info-card">
@@ -211,6 +228,28 @@ const copyToClipboard = async () => {
}
};
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands',
SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland',
RU: 'Russia', KR: 'South Korea', SG: 'Singapore', NZ: 'New Zealand',
IE: 'Ireland', CH: 'Switzerland', AT: 'Austria', BE: 'Belgium', PT: 'Portugal',
};
const getCountryName = (countryCode) => countryNames[countryCode] || countryCode;
onMounted(async () => {
await fetchData();
});
@@ -440,6 +479,15 @@ onMounted(async () => {
color: var(--muted);
}
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.loading {
text-align: center;
padding: 60px;

View File

@@ -6,13 +6,35 @@
<h1>Links</h1>
<p class="subtitle">Manage your short links</p>
</div>
<button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Link
</button>
<div class="view-toggle">
<button :class="{ active: !showDeleted }" @click="showDeleted = false">
Active
</button>
<button :class="{ active: showDeleted }" @click="toggleDeleted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Trash
</button>
</div>
<div class="header-actions">
<button @click="showBulkModal = true" class="ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Bulk Import
</button>
<button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Link
</button>
</div>
</header>
<div class="links-list" v-if="workspaceStore.links.length">
@@ -125,6 +147,60 @@
<p class="hint">Leave empty for auto-generated slug</p>
</div>
<!-- UTM Builder -->
<div class="utm-section">
<button type="button" class="utm-toggle" @click="showUtmBuilder = !showUtmBuilder">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
{{ showUtmBuilder ? 'Hide' : 'Add' }} UTM Parameters
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ rotated: showUtmBuilder }">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<div v-if="showUtmBuilder" class="utm-fields">
<div class="utm-presets">
<span class="utm-preset-label">Presets:</span>
<button type="button" @click="applyUtmPreset('google')" class="utm-preset-btn">Google Ads</button>
<button type="button" @click="applyUtmPreset('facebook')" class="utm-preset-btn">Facebook</button>
<button type="button" @click="applyUtmPreset('email')" class="utm-preset-btn">Email</button>
<button type="button" @click="applyUtmPreset('social')" class="utm-preset-btn">Social</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_source">Source</label>
<input id="utm_source" v-model="utmParams.source" type="text" placeholder="google, facebook, newsletter" />
</div>
<div class="form-group">
<label for="utm_medium">Medium</label>
<input id="utm_medium" v-model="utmParams.medium" type="text" placeholder="cpc, social, email" />
</div>
</div>
<div class="form-group">
<label for="utm_campaign">Campaign</label>
<input id="utm_campaign" v-model="utmParams.campaign" type="text" placeholder="summer_sale, product_launch" />
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_term">Term (optional)</label>
<input id="utm_term" v-model="utmParams.term" type="text" placeholder="keywords" />
</div>
<div class="form-group">
<label for="utm_content">Content (optional)</label>
<input id="utm_content" v-model="utmParams.content" type="text" placeholder="banner, textlink" />
</div>
</div>
<p v-if="utmPreview" class="utm-preview">
<strong>Preview:</strong> {{ utmPreview }}
</p>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="expiresAt">Expires (optional)</label>
@@ -171,24 +247,91 @@
</div>
</div>
</div>
<!-- Bulk Import Modal -->
<div v-if="showBulkModal" class="modal-overlay" @click.self="closeBulkModal">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Bulk Import Links</h2>
<button @click="closeBulkModal" class="close-btn">&times;</button>
</div>
<div class="bulk-content">
<p class="bulk-instructions">Paste URLs below, one per line. You can optionally add a title after the URL separated by a comma.</p>
<p class="bulk-example">Example: https://example.com, My Link Title</p>
<textarea
v-model="bulkUrls"
placeholder="https://example.com&#10;https://another-site.com, My Page&#10;https://third-url.com"
rows="10"
class="bulk-textarea"
></textarea>
<div class="bulk-stats">
<span>{{ parsedBulkLinks.length }} URLs detected</span>
</div>
<div v-if="bulkError" class="error-message">{{ bulkError }}</div>
<div v-if="bulkResults" class="bulk-results">
<div v-if="bulkResults.created.length" class="bulk-success">
<strong>{{ bulkResults.created.length }}</strong> links created successfully
</div>
<div v-if="bulkResults.errors.length" class="bulk-errors">
<strong>{{ bulkResults.errors.length }}</strong> errors:
<ul>
<li v-for="(err, i) in bulkResults.errors.slice(0, 5)" :key="i">
{{ err.url }}: {{ err.error }}
</li>
<li v-if="bulkResults.errors.length > 5">
...and {{ bulkResults.errors.length - 5 }} more
</li>
</ul>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="closeBulkModal" class="ghost">{{ bulkResults ? 'Close' : 'Cancel' }}</button>
<button
v-if="!bulkResults"
@click="importBulkLinks"
class="cta"
:disabled="bulkImporting || parsedBulkLinks.length === 0"
>
{{ bulkImporting ? 'Importing...' : `Import ${parsedBulkLinks.length} Links` }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const workspaceStore = useWorkspaceStore();
const showCreateModal = ref(false);
const showDeleteModal = ref(false);
const showBulkModal = ref(false);
const showUtmBuilder = ref(false);
const showDeleted = ref(false);
const editingLink = ref(null);
const deletingLink = ref(null);
const saving = ref(false);
const formError = ref('');
// Bulk import state
const bulkUrls = ref('');
const bulkImporting = ref(false);
const bulkError = ref('');
const bulkResults = ref(null);
const formData = ref({
destinationUrl: '',
title: '',
@@ -197,6 +340,44 @@ const formData = ref({
password: '',
});
const utmParams = ref({
source: '',
medium: '',
campaign: '',
term: '',
content: '',
});
const utmPresets = {
google: { source: 'google', medium: 'cpc', campaign: '' },
facebook: { source: 'facebook', medium: 'social', campaign: '' },
email: { source: 'newsletter', medium: 'email', campaign: '' },
social: { source: 'twitter', medium: 'social', campaign: '' },
};
const applyUtmPreset = (preset) => {
const p = utmPresets[preset];
if (p) {
utmParams.value = { ...utmParams.value, ...p };
}
};
const utmPreview = computed(() => {
const params = [];
if (utmParams.value.source) params.push(`utm_source=${utmParams.value.source}`);
if (utmParams.value.medium) params.push(`utm_medium=${utmParams.value.medium}`);
if (utmParams.value.campaign) params.push(`utm_campaign=${utmParams.value.campaign}`);
if (utmParams.value.term) params.push(`utm_term=${utmParams.value.term}`);
if (utmParams.value.content) params.push(`utm_content=${utmParams.value.content}`);
return params.length ? '?' + params.join('&') : '';
});
const buildUrlWithUtm = (baseUrl) => {
if (!utmPreview.value) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
return baseUrl + separator + utmPreview.value.substring(1);
};
const resetForm = () => {
formData.value = {
destinationUrl: '',
@@ -205,6 +386,14 @@ const resetForm = () => {
expiresAt: '',
password: '',
};
utmParams.value = {
source: '',
medium: '',
campaign: '',
term: '',
content: '',
};
showUtmBuilder.value = false;
formError.value = '';
editingLink.value = null;
};
@@ -231,8 +420,11 @@ const saveLink = async () => {
formError.value = '';
try {
// Build destination URL with UTM parameters
const finalUrl = buildUrlWithUtm(formData.value.destinationUrl);
const data = {
destinationUrl: formData.value.destinationUrl,
destinationUrl: finalUrl,
title: formData.value.title || null,
expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null,
password: formData.value.password || null,
@@ -287,15 +479,84 @@ const copyToClipboard = async (text) => {
}
};
// Bulk import functions
const parsedBulkLinks = computed(() => {
if (!bulkUrls.value.trim()) return [];
return bulkUrls.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => {
const parts = line.split(',');
const url = parts[0].trim();
const title = parts.length > 1 ? parts.slice(1).join(',').trim() : null;
return { destinationUrl: url, title };
})
.filter(item => {
try {
new URL(item.destinationUrl);
return true;
} catch {
return false;
}
});
});
const closeBulkModal = () => {
showBulkModal.value = false;
bulkUrls.value = '';
bulkError.value = '';
bulkResults.value = null;
};
const importBulkLinks = async () => {
if (parsedBulkLinks.value.length === 0) return;
bulkImporting.value = true;
bulkError.value = '';
try {
const workspaceId = workspaceStore.currentWorkspaceId;
const result = await api.bulkCreateLinks(workspaceId, parsedBulkLinks.value);
bulkResults.value = result;
// Refresh links list
await workspaceStore.fetchLinks();
} catch (err) {
bulkError.value = err.message;
} finally {
bulkImporting.value = false;
}
};
const toggleDeleted = async () => {
showDeleted.value = true;
await workspaceStore.fetchLinks({ includeDeleted: true });
};
const restoreLink = async (link) => {
try {
await api.restoreLink(workspaceStore.currentWorkspaceId, link.id);
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
} catch (err) {
console.error('Failed to restore link:', err);
}
};
onMounted(async () => {
await workspaceStore.fetchLinks();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchLinks();
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
}
});
watch(showDeleted, async (value) => {
await workspaceStore.fetchLinks({ includeDeleted: value });
});
</script>
<style scoped>
@@ -581,6 +842,176 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
background: #dc2626;
}
/* UTM Builder styles */
.utm-section {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.utm-toggle {
width: 100%;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-toggle:hover {
background: var(--line);
color: var(--ink);
}
.utm-toggle svg:last-child {
margin-left: auto;
transition: transform 0.2s ease;
}
.utm-toggle svg.rotated {
transform: rotate(180deg);
}
.utm-fields {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border-top: 1px solid var(--line);
}
.utm-presets {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.utm-preset-label {
font-size: 0.85rem;
color: var(--muted);
}
.utm-preset-btn {
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--ink);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-preset-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.utm-preview {
padding: 12px;
background: var(--bg);
border-radius: 8px;
font-size: 0.85rem;
color: var(--muted);
word-break: break-all;
}
.utm-preview strong {
color: var(--ink);
}
/* Bulk import styles */
.header-actions {
display: flex;
gap: 12px;
}
.modal-lg {
max-width: 600px;
}
.bulk-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.bulk-instructions {
color: var(--ink);
font-size: 0.95rem;
}
.bulk-example {
color: var(--muted);
font-size: 0.85rem;
font-style: italic;
}
.bulk-textarea {
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 0.9rem;
font-family: monospace;
resize: vertical;
line-height: 1.6;
}
.bulk-textarea:focus {
outline: none;
border-color: var(--accent);
}
.bulk-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.875rem;
color: var(--muted);
}
.bulk-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.bulk-success {
padding: 12px 16px;
background: #dcfce7;
border-radius: 10px;
color: #16a34a;
font-size: 0.9rem;
}
.bulk-errors {
padding: 12px 16px;
background: #fef2f2;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.bulk-errors ul {
margin: 8px 0 0;
padding-left: 20px;
}
.bulk-errors li {
margin-top: 4px;
font-size: 0.85rem;
}
@media (max-width: 600px) {
.link-card {
flex-direction: column;

View File

@@ -0,0 +1,566 @@
<template>
<AppLayout>
<div class="projects">
<header class="page-header">
<div>
<h1>Projects</h1>
<p class="subtitle">Organize your links and QR codes into projects</p>
</div>
<button class="btn btn-primary" @click="showCreateModal = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Project
</button>
</header>
<div v-if="loading" class="loading">Loading projects...</div>
<div v-else-if="projects.length === 0" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3>No projects yet</h3>
<p>Create your first project to organize your links and QR codes.</p>
<button class="btn btn-primary" @click="showCreateModal = true">Create Project</button>
</div>
<div v-else class="projects-grid">
<div v-for="project in projects" :key="project.id" class="project-card">
<div class="project-header">
<div class="project-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="project-actions">
<button class="icon-btn" @click="editProject(project)" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="icon-btn danger" @click="confirmDelete(project)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
<h3 class="project-name">{{ project.name }}</h3>
<p class="project-description">{{ project.description || 'No description' }}</p>
<div class="project-meta">
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{{ project.linkCount || 0 }} links
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<rect x="7" y="7" width="3" height="3"/>
<rect x="14" y="7" width="3" height="3"/>
<rect x="7" y="14" width="3" height="3"/>
<rect x="14" y="14" width="3" height="3"/>
</svg>
{{ project.qrCodeCount || 0 }} QR codes
</span>
<span class="meta-item">
Created {{ formatDate(project.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click.self="closeModals">
<div class="modal">
<div class="modal-header">
<h2>{{ showEditModal ? 'Edit Project' : 'New Project' }}</h2>
<button class="close-btn" @click="closeModals">&times;</button>
</div>
<form @submit.prevent="showEditModal ? updateProject() : createProject()">
<div class="form-group">
<label for="name">Project Name</label>
<input
id="name"
v-model="form.name"
type="text"
placeholder="e.g., Marketing Campaign Q1"
required
autofocus
/>
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea
id="description"
v-model="form.description"
placeholder="Describe what this project is for..."
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="closeModals">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (showEditModal ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Project</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ projectToDelete?.name }}</strong>?
This action cannot be undone.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteProject" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const projects = computed(() => workspaceStore.projects);
const loading = ref(true);
const saving = ref(false);
const deleting = ref(false);
const error = ref('');
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const form = ref({ name: '', description: '' });
const editingProject = ref(null);
const projectToDelete = ref(null);
onMounted(async () => {
await loadProjects();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadProjects();
}
});
async function loadProjects() {
loading.value = true;
try {
await workspaceStore.fetchProjects();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function createProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.createProject(form.value.name, form.value.description);
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function editProject(project) {
editingProject.value = project;
form.value = { name: project.name, description: project.description || '' };
showEditModal.value = true;
}
async function updateProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.updateProject(editingProject.value.id, {
name: form.value.name,
description: form.value.description
});
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function confirmDelete(project) {
projectToDelete.value = project;
showDeleteModal.value = true;
}
async function deleteProject() {
deleting.value = true;
try {
await workspaceStore.deleteProject(projectToDelete.value.id);
showDeleteModal.value = false;
projectToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function closeModals() {
showCreateModal.value = false;
showEditModal.value = false;
form.value = { name: '', description: '' };
editingProject.value = null;
error.value = '';
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.projects {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.project-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.project-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.project-icon {
width: 40px;
height: 40px;
background: #f3f4f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
}
.project-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
color: #374151;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
.project-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.project-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
line-height: 1.5;
}
.project-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -142,6 +142,84 @@
/>
<span class="range-value">{{ formData.style.quietZone }} modules</span>
</div>
<div class="form-group">
<label>Module Shape</label>
<div class="shape-selector">
<button
v-for="shape in moduleShapes"
:key="shape.value"
:class="{ active: formData.style.moduleShape === shape.value }"
@click="formData.style.moduleShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
</div>
<div class="form-group">
<label>Eye Shape</label>
<div class="shape-selector">
<button
v-for="shape in eyeShapes"
:key="shape.value"
:class="{ active: formData.style.eyeShape === shape.value }"
@click="formData.style.eyeShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
<p class="hint">Eyes are the large corner patterns for scanner detection</p>
</div>
</div>
<div class="settings-card">
<h2>Logo</h2>
<div class="logo-section">
<div class="current-logo" v-if="formData.logoAssetId">
<img :src="getLogoUrl(formData.logoAssetId)" alt="Current logo" class="logo-preview" />
<button @click="removeLogo" class="remove-logo-btn" title="Remove logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="logo-upload" v-else>
<label class="upload-btn">
<input
type="file"
@change="handleLogoUpload"
accept="image/png,image/jpeg,image/svg+xml"
hidden
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload Logo
</label>
</div>
<div v-if="assets.length > 0 && !formData.logoAssetId" class="existing-logos">
<p class="hint">Or select from existing:</p>
<div class="logos-grid">
<button
v-for="asset in assets"
:key="asset.id"
@click="selectLogo(asset)"
class="logo-option"
>
<img :src="asset.url" :alt="asset.filename" />
</button>
</div>
</div>
<p class="hint" v-if="formData.logoAssetId">Use high error correction (H) for better logo visibility</p>
</div>
</div>
<div class="settings-card">
@@ -188,42 +266,96 @@ const saving = ref(false);
const error = ref('');
const previewUrl = ref('');
const previewTimeout = ref(null);
const uploadingLogo = ref(false);
const assets = computed(() => workspaceStore.assets.filter(a => a.type === 'Logo'));
const formData = ref({
name: '',
linkId: '',
logoAssetId: null,
style: {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
moduleShape: 'Square',
eyeShape: 'Square',
},
});
const moduleShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Dots', label: 'Dots' },
];
const eyeShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Circle', label: 'Circle' },
];
const getLogoUrl = (assetId) => {
const asset = workspaceStore.assets.find(a => a.id === assetId);
return asset?.url || '';
};
const handleLogoUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
uploadingLogo.value = true;
try {
const asset = await workspaceStore.uploadAsset(file);
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
} catch (err) {
error.value = 'Failed to upload logo: ' + err.message;
} finally {
uploadingLogo.value = false;
event.target.value = '';
}
};
const selectLogo = (asset) => {
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
};
const removeLogo = () => {
formData.value.logoAssetId = null;
};
const presets = [
{
name: 'Classic',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
},
{
name: 'Dark',
style: { foregroundColor: '#ffffff', backgroundColor: '#1a1a1a', errorCorrectionLevel: 'M', quietZone: 4 }
name: 'Modern',
style: { foregroundColor: '#1a1a1a', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
},
{
name: 'Dots',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'H', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
},
{
name: 'Ocean',
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
},
{
name: 'Forest',
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Sunset',
style: { foregroundColor: '#c2410c', backgroundColor: '#fff7ed', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
},
{
name: 'Purple',
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4 }
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
},
];
@@ -258,11 +390,16 @@ const save = async () => {
try {
const data = {
name: formData.value.name,
linkId: formData.value.linkId,
shortLinkId: formData.value.linkId,
logoAssetId: formData.value.logoAssetId,
style: formData.value.style,
};
if (isEditing.value) {
// For updates, handle logo removal
if (!formData.value.logoAssetId) {
data.removeLogo = true;
}
await workspaceStore.updateQRCode(route.params.id, data);
} else {
await workspaceStore.createQRCode(data);
@@ -291,15 +428,19 @@ const loadExisting = async () => {
try {
const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id);
const defaultStyle = {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
moduleShape: 'Square',
eyeShape: 'Square',
};
formData.value = {
name: qr.name,
linkId: qr.linkId,
style: qr.style || {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
},
linkId: qr.shortLinkId || qr.linkId,
logoAssetId: qr.logoAssetId || null,
style: { ...defaultStyle, ...qr.style },
};
await fetchPreview();
} catch (err) {
@@ -312,8 +453,16 @@ watch(() => formData.value.style, () => {
previewTimeout.value = setTimeout(fetchPreview, 500);
}, { deep: true });
watch(() => formData.value.logoAssetId, () => {
if (previewTimeout.value) clearTimeout(previewTimeout.value);
previewTimeout.value = setTimeout(fetchPreview, 500);
});
onMounted(async () => {
await workspaceStore.fetchLinks();
await Promise.all([
workspaceStore.fetchLinks(),
workspaceStore.fetchAssets(),
]);
if (isEditing.value) {
await loadExisting();
}
@@ -519,6 +668,150 @@ onMounted(async () => {
font-weight: 500;
}
.logo-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.current-logo {
position: relative;
display: inline-block;
width: 80px;
height: 80px;
}
.logo-preview {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--bg);
}
.remove-logo-btn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #dc2626;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.remove-logo-btn:hover {
background: #b91c1c;
}
.logo-upload {
display: flex;
gap: 12px;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg);
border: 2px dashed var(--line);
border-radius: 12px;
cursor: pointer;
font-weight: 500;
transition: all 0.15s ease;
}
.upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.existing-logos {
margin-top: 8px;
}
.logos-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.logo-option {
width: 48px;
height: 48px;
padding: 4px;
border: 2px solid var(--line);
border-radius: 8px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.logo-option:hover {
border-color: var(--accent);
}
.logo-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shape-selector {
display: flex;
gap: 8px;
}
.shape-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 8px;
border: 2px solid var(--line);
border-radius: 12px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.shape-btn:hover {
border-color: var(--muted);
}
.shape-btn.active {
border-color: var(--accent);
background: rgba(255, 106, 61, 0.1);
}
.shape-icon {
width: 24px;
height: 24px;
background: var(--ink);
}
.shape-icon.square {
border-radius: 2px;
}
.shape-icon.rounded {
border-radius: 6px;
}
.shape-icon.dots,
.shape-icon.circle {
border-radius: 50%;
}
@media (max-width: 900px) {
.designer-grid {
grid-template-columns: 1fr;

View File

@@ -0,0 +1,514 @@
<template>
<AppLayout>
<div class="qrcode-detail">
<header class="page-header">
<div class="header-left">
<router-link to="/qrcodes" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back to QR Codes
</router-link>
<h1>{{ qrCode?.name || 'QR Code' }}</h1>
</div>
<div class="header-actions">
<router-link :to="`/qrcodes/${id}`" class="btn btn-secondary">
Edit Design
</router-link>
</div>
</header>
<div v-if="loading" class="loading">Loading analytics...</div>
<template v-else-if="analytics">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon scans">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
<p class="stat-label">Total Scans</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon visitors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
</div>
</div>
<!-- Period Selector -->
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
<!-- Chart -->
<section class="card chart-card">
<h2>Scans Over Time</h2>
<div class="chart-container" v-if="analytics.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar"
:style="{ height: getBarHeight(point.scans) + '%' }"
:title="`${point.date}: ${point.scans} scans`"
></div>
</div>
</div>
<div v-else class="empty-state">
<p>No scan data for this period</p>
</div>
</section>
<!-- Breakdowns -->
<div class="breakdown-grid">
<!-- Device Breakdown -->
<section class="card">
<h2>Devices</h2>
<div v-if="Object.keys(analytics.deviceBreakdown).length" class="breakdown-list">
<div
v-for="(count, device) in analytics.deviceBreakdown"
:key="device"
class="breakdown-item"
>
<span class="breakdown-label">{{ device }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data</p>
</div>
</section>
<!-- Country Breakdown -->
<section class="card">
<h2>Countries</h2>
<div v-if="Object.keys(analytics.countryBreakdown).length" class="breakdown-list">
<div
v-for="(count, country) in analytics.countryBreakdown"
:key="country"
class="breakdown-item"
>
<span class="breakdown-label">
{{ getCountryFlag(country) }} {{ getCountryName(country) }}
</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No country data</p>
</div>
</section>
<!-- Referrer Breakdown -->
<section class="card">
<h2>Referrers</h2>
<div v-if="Object.keys(analytics.referrerBreakdown).length" class="breakdown-list">
<div
v-for="(count, referrer) in analytics.referrerBreakdown"
:key="referrer"
class="breakdown-item"
>
<span class="breakdown-label">{{ referrer }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data</p>
</div>
</section>
</div>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const id = computed(() => route.params.id);
const loading = ref(true);
const error = ref('');
const qrCode = ref(null);
const analytics = ref(null);
const period = ref('7d');
const periods = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: 'all', label: 'All' }
];
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands'
};
onMounted(() => {
loadData();
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) return;
const [qrData, analyticsData] = await Promise.all([
api.getQRCode(workspaceId, id.value),
api.getQRCodeAnalytics(workspaceId, id.value, period.value)
]);
qrCode.value = qrData;
analytics.value = analyticsData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function setPeriod(newPeriod) {
period.value = newPeriod;
loading.value = true;
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
analytics.value = await api.getQRCodeAnalytics(workspaceId, id.value, newPeriod);
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
function getBarHeight(value) {
if (!analytics.value?.timeSeries?.length) return 0;
const max = Math.max(...analytics.value.timeSeries.map(p => p.scans));
return max > 0 ? (value / max) * 100 : 0;
}
function getBreakdownPercent(count, total) {
return total > 0 ? (count / total) * 100 : 0;
}
function getCountryFlag(code) {
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountryName(code) {
return countryNames[code] || code;
}
</script>
<style scoped>
.qrcode-detail {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
text-decoration: none;
}
.back-link:hover {
color: #374151;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.scans {
background: #dbeafe;
color: #2563eb;
}
.stat-icon.visitors {
background: #d1fae5;
color: #059669;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.period-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.period-selector button {
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
background: white;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.period-selector button:hover {
background: #f9fafb;
}
.period-selector button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-container {
height: 200px;
display: flex;
align-items: flex-end;
}
.chart {
display: flex;
align-items: flex-end;
gap: 4px;
width: 100%;
height: 100%;
}
.chart-bar {
flex: 1;
background: #3b82f6;
border-radius: 4px 4px 0 0;
min-height: 4px;
transition: height 0.3s ease;
}
.breakdown-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-item {
display: flex;
align-items: center;
gap: 1rem;
}
.breakdown-label {
width: 120px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breakdown-bar-container {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.breakdown-bar {
height: 100%;
background: #3b82f6;
border-radius: 4px;
}
.breakdown-count {
width: 50px;
text-align: right;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.empty-state p {
margin: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #f9fafb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.breakdown-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,991 @@
<template>
<AppLayout>
<div class="settings">
<header class="page-header">
<h1>Account Settings</h1>
<p class="subtitle">Manage your account and preferences</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Profile Section -->
<section class="settings-section">
<div class="section-header">
<h2>Profile</h2>
</div>
<div class="section-content">
<form @submit.prevent="updateProfile">
<div class="form-group">
<label for="email">Email Address</label>
<input
id="email"
v-model="profile.email"
type="email"
required
/>
<p v-if="!profile.isVerified" class="form-warning">
Your email is not verified.
<button type="button" class="link-btn" @click="resendVerification" :disabled="resendingVerification">
{{ resendingVerification ? 'Sending...' : 'Resend verification email' }}
</button>
</p>
</div>
<div v-if="profileError" class="error-message">{{ profileError }}</div>
<div v-if="profileSuccess" class="success-message">{{ profileSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="savingProfile">
{{ savingProfile ? 'Saving...' : 'Save Changes' }}
</button>
</form>
</div>
</section>
<!-- Password Section -->
<section class="settings-section">
<div class="section-header">
<h2>Change Password</h2>
</div>
<div class="section-content">
<form @submit.prevent="changePassword">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input
id="currentPassword"
v-model="passwordForm.currentPassword"
type="password"
required
/>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input
id="newPassword"
v-model="passwordForm.newPassword"
type="password"
required
minlength="8"
/>
<p class="form-hint">At least 8 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input
id="confirmPassword"
v-model="passwordForm.confirmPassword"
type="password"
required
/>
</div>
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
<div v-if="passwordSuccess" class="success-message">{{ passwordSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
{{ changingPassword ? 'Changing...' : 'Change Password' }}
</button>
</form>
</div>
</section>
<!-- API Keys Section -->
<section class="settings-section">
<div class="section-header">
<h2>API Keys</h2>
<button class="btn btn-primary btn-sm" @click="showCreateKeyModal = true">
Create Key
</button>
</div>
<div class="section-content">
<p class="section-description">Generate API keys to access TrakQR programmatically. Keys are scoped to your current workspace.</p>
<div v-if="loadingKeys" class="loading-inline">Loading API keys...</div>
<div v-else-if="apiKeys.length === 0" class="empty-state-inline">
<p>No API keys yet. Create one to get started.</p>
</div>
<div v-else class="api-keys-list">
<div v-for="key in apiKeys" :key="key.id" class="api-key-item">
<div class="api-key-info">
<span class="api-key-name">{{ key.name }}</span>
<code class="api-key-prefix">{{ key.keyPrefix }}</code>
</div>
<div class="api-key-meta">
<span v-if="key.lastUsedAt" class="api-key-used">
Last used {{ formatDate(key.lastUsedAt) }}
</span>
<span v-else class="api-key-used">Never used</span>
<span v-if="key.expiresAt" :class="['api-key-expiry', { expired: isExpired(key.expiresAt) }]">
{{ isExpired(key.expiresAt) ? 'Expired' : `Expires ${formatDate(key.expiresAt)}` }}
</span>
</div>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteKey(key)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
</section>
<!-- Danger Zone -->
<section class="settings-section danger-zone">
<div class="section-header">
<h2>Danger Zone</h2>
</div>
<div class="section-content">
<div class="danger-item">
<div>
<h4>Delete Account</h4>
<p>Permanently delete your account and all associated data. This action cannot be undone.</p>
</div>
<button class="btn btn-danger" @click="showDeleteModal = true">
Delete Account
</button>
</div>
</div>
</section>
</template>
<!-- Create API Key Modal -->
<div v-if="showCreateKeyModal" class="modal-overlay" @click.self="showCreateKeyModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">Create API Key</h2>
<button class="close-btn" @click="showCreateKeyModal = false">&times;</button>
</div>
<form @submit.prevent="createApiKey">
<div class="modal-body">
<div class="form-group">
<label for="keyName">Key Name</label>
<input
id="keyName"
v-model="newKeyName"
type="text"
required
placeholder="e.g., Production Server"
/>
</div>
<div class="form-group">
<label for="keyExpiry">Expiration (optional)</label>
<select id="keyExpiry" v-model="newKeyExpiry">
<option value="">Never expires</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
</div>
<div v-if="createKeyError" class="error-message">{{ createKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateKeyModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingKey">
{{ creatingKey ? 'Creating...' : 'Create Key' }}
</button>
</div>
</form>
</div>
</div>
<!-- Show New Key Modal -->
<div v-if="newlyCreatedKey" class="modal-overlay">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">API Key Created</h2>
</div>
<div class="modal-body">
<p class="key-warning">
Copy your API key now. You won't be able to see it again!
</p>
<div class="key-display">
<code>{{ newlyCreatedKey }}</code>
<button type="button" class="btn btn-icon" @click="copyKey" :title="keyCopied ? 'Copied!' : 'Copy'">
<svg v-if="!keyCopied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" @click="newlyCreatedKey = null">Done</button>
</div>
</div>
</div>
<!-- Delete API Key Modal -->
<div v-if="keyToDelete" class="modal-overlay" @click.self="keyToDelete = null">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete API Key?</h2>
<button class="close-btn" @click="keyToDelete = null">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the API key "{{ keyToDelete.name }}"? Any applications using this key will stop working.</p>
<div v-if="deleteKeyError" class="error-message">{{ deleteKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="keyToDelete = null">Cancel</button>
<button type="button" class="btn btn-danger" @click="deleteApiKey" :disabled="deletingKey">
{{ deletingKey ? 'Deleting...' : 'Delete Key' }}
</button>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Account</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<form @submit.prevent="deleteAccount">
<div class="modal-body">
<p class="delete-warning">
This will permanently delete your account, all workspaces, links, QR codes, and analytics data.
This action <strong>cannot be undone</strong>.
</p>
<div class="form-group">
<label for="deletePassword">Enter your password to confirm</label>
<input
id="deletePassword"
v-model="deletePassword"
type="password"
required
placeholder="Your password"
/>
</div>
<div v-if="deleteError" class="error-message">{{ deleteError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button type="submit" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete My Account' }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const profile = ref({ email: '', isVerified: false });
const savingProfile = ref(false);
const profileError = ref('');
const profileSuccess = ref('');
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const changingPassword = ref(false);
const passwordError = ref('');
const passwordSuccess = ref('');
const showDeleteModal = ref(false);
const deletePassword = ref('');
const deleting = ref(false);
const deleteError = ref('');
// API Keys state
const apiKeys = ref([]);
const loadingKeys = ref(false);
const showCreateKeyModal = ref(false);
const newKeyName = ref('');
const newKeyExpiry = ref('');
const creatingKey = ref(false);
const createKeyError = ref('');
const newlyCreatedKey = ref(null);
const keyCopied = ref(false);
const keyToDelete = ref(null);
const deletingKey = ref(false);
const deleteKeyError = ref('');
onMounted(async () => {
try {
const data = await api.getProfile();
profile.value = data;
await loadApiKeys();
} catch (err) {
profileError.value = err.message;
} finally {
loading.value = false;
}
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadApiKeys();
}
});
async function loadApiKeys() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
loadingKeys.value = true;
try {
const result = await api.listApiKeys(workspaceId);
apiKeys.value = result.apiKeys;
} catch (err) {
console.error('Failed to load API keys:', err);
} finally {
loadingKeys.value = false;
}
}
async function updateProfile() {
savingProfile.value = true;
profileError.value = '';
profileSuccess.value = '';
try {
const data = await api.updateProfile({ email: profile.value.email });
profile.value = data;
profileSuccess.value = 'Profile updated successfully';
} catch (err) {
profileError.value = err.message;
} finally {
savingProfile.value = false;
}
}
async function changePassword() {
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
passwordError.value = 'Passwords do not match';
return;
}
changingPassword.value = true;
passwordError.value = '';
passwordSuccess.value = '';
try {
await api.changePassword(
passwordForm.value.currentPassword,
passwordForm.value.newPassword
);
passwordSuccess.value = 'Password changed successfully';
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' };
} catch (err) {
passwordError.value = err.message;
} finally {
changingPassword.value = false;
}
}
const resendingVerification = ref(false);
async function resendVerification() {
resendingVerification.value = true;
profileError.value = '';
try {
await api.resendVerification();
profileSuccess.value = 'Verification email sent! Check your inbox.';
} catch (err) {
profileError.value = err.message;
} finally {
resendingVerification.value = false;
}
}
async function deleteAccount() {
deleting.value = true;
deleteError.value = '';
try {
await api.deleteAccount(deletePassword.value);
authStore.logout();
router.push('/');
} catch (err) {
deleteError.value = err.message;
} finally {
deleting.value = false;
}
}
// API Key functions
async function createApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
creatingKey.value = true;
createKeyError.value = '';
try {
let expiresAt = null;
if (newKeyExpiry.value) {
const date = new Date();
date.setDate(date.getDate() + parseInt(newKeyExpiry.value));
expiresAt = date.toISOString();
}
const result = await api.createApiKey(workspaceId, newKeyName.value, expiresAt);
newlyCreatedKey.value = result.key;
apiKeys.value.unshift({
id: result.id,
name: result.name,
keyPrefix: result.keyPrefix,
scopes: result.scopes,
expiresAt: result.expiresAt,
createdAt: result.createdAt,
isActive: true,
});
showCreateKeyModal.value = false;
newKeyName.value = '';
newKeyExpiry.value = '';
} catch (err) {
createKeyError.value = err.message;
} finally {
creatingKey.value = false;
}
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newlyCreatedKey.value);
keyCopied.value = true;
setTimeout(() => {
keyCopied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function confirmDeleteKey(key) {
keyToDelete.value = key;
deleteKeyError.value = '';
}
async function deleteApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId || !keyToDelete.value) return;
deletingKey.value = true;
deleteKeyError.value = '';
try {
await api.deleteApiKey(workspaceId, keyToDelete.value.id);
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value.id);
keyToDelete.value = null;
} catch (err) {
deleteKeyError.value = err.message;
} finally {
deletingKey.value = false;
}
}
function formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function isExpired(date) {
if (!date) return false;
return new Date(date) < new Date();
}
</script>
<style scoped>
.settings {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.settings-section {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.section-header h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: #e5e7eb;
color: #6b7280;
border-radius: 4px;
}
.section-content {
padding: 1.5rem;
}
.section-content.muted {
color: #6b7280;
}
.section-content.muted p {
margin: 0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-of-type {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.form-warning {
font-size: 0.875rem;
color: #d97706;
margin: 0.5rem 0 0;
}
.link-btn {
background: none;
border: none;
color: #3b82f6;
text-decoration: underline;
cursor: pointer;
font-size: inherit;
}
.link-btn:hover {
color: #2563eb;
}
/* Danger Zone */
.danger-zone .section-header {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.danger-zone .section-header h2 {
color: #dc2626;
}
.danger-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.danger-item h4 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.danger-item p {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Messages */
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.success-message {
background: #d1fae5;
border: 1px solid #a7f3d0;
border-radius: 8px;
padding: 0.75rem;
color: #065f46;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 420px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: #dc2626;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
/* API Keys */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.section-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
}
.loading-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem 0;
}
.empty-state-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
text-align: center;
}
.empty-state-inline p {
margin: 0;
}
.api-keys-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.api-key-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.api-key-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.api-key-name {
font-weight: 500;
font-size: 0.9rem;
}
.api-key-prefix {
font-family: monospace;
font-size: 0.8rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
width: fit-content;
}
.api-key-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.api-key-expiry.expired {
color: #dc2626;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
}
.btn-icon:hover {
background: #e5e7eb;
}
.btn-danger-icon:hover {
background: #fee2e2;
color: #dc2626;
}
.modal-title-normal {
color: #111827 !important;
}
.key-warning {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 0.75rem;
color: #92400e;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.key-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1f2937;
border-radius: 8px;
padding: 0.75rem;
}
.key-display code {
flex: 1;
color: #10b981;
font-family: monospace;
font-size: 0.8rem;
word-break: break-all;
}
.key-display .btn-icon {
color: #9ca3af;
flex-shrink: 0;
}
.key-display .btn-icon:hover {
color: white;
background: transparent;
}
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
background: white;
}
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
@media (max-width: 640px) {
.danger-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-meta {
flex-direction: row;
align-items: center;
gap: 1rem;
width: 100%;
justify-content: space-between;
}
}
</style>