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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">×</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">×</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">×</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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
215
src/frontend/src/views/auth/ForgotPassword.vue
Normal file
215
src/frontend/src/views/auth/ForgotPassword.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
231
src/frontend/src/views/auth/ResetPassword.vue
Normal file
231
src/frontend/src/views/auth/ResetPassword.vue
Normal 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>
|
||||
244
src/frontend/src/views/auth/VerifyEmail.vue
Normal file
244
src/frontend/src/views/auth/VerifyEmail.vue
Normal 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>
|
||||
598
src/frontend/src/views/billing/Billing.vue
Normal file
598
src/frontend/src/views/billing/Billing.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
761
src/frontend/src/views/domains/Domains.vue
Normal file
761
src/frontend/src/views/domains/Domains.vue
Normal 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">×</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">×</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">×</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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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">×</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 https://another-site.com, My Page 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;
|
||||
|
||||
566
src/frontend/src/views/projects/Projects.vue
Normal file
566
src/frontend/src/views/projects/Projects.vue
Normal 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">×</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">×</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>
|
||||
@@ -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;
|
||||
|
||||
514
src/frontend/src/views/qrcodes/QRCodeDetail.vue
Normal file
514
src/frontend/src/views/qrcodes/QRCodeDetail.vue
Normal 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>
|
||||
991
src/frontend/src/views/settings/Settings.vue
Normal file
991
src/frontend/src/views/settings/Settings.vue
Normal 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">×</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">×</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">×</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>
|
||||
Reference in New Issue
Block a user