feat: Implement push notifications in Budget Manager PWA
- Added functionality for subscribing to push notifications in Settings.vue. - Integrated service worker registration and notification permission requests. - Enhanced Vite configuration for PWA with manifest and caching strategies. - Created a script for generating PDF reports. - Added project report markdown file detailing the PWA implementation and features.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
@@ -20,6 +21,7 @@
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.14",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.7",
|
||||
},
|
||||
@@ -308,6 +310,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -562,6 +566,8 @@
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chart.js": ["chart.js@4.4.9", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
@@ -1148,6 +1154,8 @@
|
||||
|
||||
"vue": ["vue@3.5.14", "", { "dependencies": { "@vue/compiler-dom": "3.5.14", "@vue/compiler-sfc": "3.5.14", "@vue/runtime-dom": "3.5.14", "@vue/server-renderer": "3.5.14", "@vue/shared": "3.5.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbOm50/vZFG6Mhy6KscQYXZMQ0LMCC/y40HDJPPvGFQ+i/lUH+PJHR6C3assgOQiXdl6tAfsXHbXYVBZZu65ew=="],
|
||||
|
||||
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
||||
|
||||
+27
-2
@@ -67,7 +67,7 @@ if (!self.define) {
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
@@ -82,11 +82,36 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.i9edo79oi9g"
|
||||
"revision": "0.luk243ceho"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "google-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "gstatic-fonts-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 31536000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\/api\/(?!.*auth).*/i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 3600
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.14",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.7"
|
||||
},
|
||||
|
||||
+105
-9
@@ -110,24 +110,120 @@ self.addEventListener("sync", (event) => {
|
||||
|
||||
// Push notification handler
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data.json();
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (error) {
|
||||
// If the data is not JSON, try to get it as text
|
||||
data = {
|
||||
message: event.data ? event.data.text() : "New notification",
|
||||
additionalData: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Format transaction type for display
|
||||
let messageText = data.message || "New transaction";
|
||||
|
||||
// Default notification options
|
||||
const options = {
|
||||
body: data.body,
|
||||
body: messageText,
|
||||
icon: "/icons/icon-192px.jpg",
|
||||
badge: "/icons/badge-96x96.png",
|
||||
data: data.data,
|
||||
actions: data.actions || [],
|
||||
badge: "/icons/icon-192px.jpg",
|
||||
vibrate: [100, 50, 100],
|
||||
data: data.additionalData || {},
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View Details'
|
||||
}
|
||||
],
|
||||
// Tag transactions by ID to prevent duplicate notifications
|
||||
tag: data.additionalData?.transactionId || 'transaction'
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||
|
||||
// Show different icons based on transaction type
|
||||
if (data.additionalData?.type === 'income') {
|
||||
options.icon = "/icons/income-icon.jpg";
|
||||
} else if (data.additionalData?.type === 'expense') {
|
||||
options.icon = "/icons/expense-icon.jpg";
|
||||
}
|
||||
|
||||
console.log('Showing notification:', messageText);
|
||||
|
||||
// Forward to any open clients
|
||||
forwardNotificationToClients(data);
|
||||
|
||||
// Show system notification
|
||||
event.waitUntil(self.registration.showNotification("Budget Manager", options));
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
// Get transaction ID from notification data
|
||||
const transactionId = event.notification.data?.transactionId;
|
||||
|
||||
// Handle different notification actions
|
||||
if (event.action === 'view' && transactionId) {
|
||||
// Open transaction details page
|
||||
event.waitUntil(
|
||||
clients.matchAll({type: 'window'})
|
||||
.then(clientList => {
|
||||
// Check if there is already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes('/') && 'focus' in client) {
|
||||
client.navigate(`/edit/${transactionId}`);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// If no window is open, open a new one
|
||||
return clients.openWindow(`/edit/${transactionId}`);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Default: open the app
|
||||
event.waitUntil(
|
||||
clients.matchAll({type: 'window'})
|
||||
.then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes('/') && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
event.waitUntil(clients.openWindow("/"));
|
||||
// Forward push notifications to all clients
|
||||
function forwardNotificationToClients(data) {
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
// Send message to client
|
||||
client.postMessage({
|
||||
type: 'PUSH_NOTIFICATION',
|
||||
title: 'Budget Manager',
|
||||
body: data.message,
|
||||
transactionId: data.additionalData?.transactionId,
|
||||
type: data.additionalData?.type
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for messages from clients
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'GET_AUTH_TOKEN') {
|
||||
// Token request from service worker
|
||||
self.clients.matchAll().then(clients => {
|
||||
if (clients.length > 0) {
|
||||
const token = localStorage.getItem('token');
|
||||
event.ports[0].postMessage({ token });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync transactions with the server
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from "vue";
|
||||
import NotificationsProvider from "@/components/NotificationsProvider.vue";
|
||||
|
||||
// 'ref' is like React's useState - creates a reactive variable
|
||||
// In React: const [isPageLoaded, setIsPageLoaded] = useState(false)
|
||||
@@ -60,6 +61,9 @@
|
||||
-->
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</router-view>
|
||||
|
||||
<!-- Notifications provider for in-app push notifications -->
|
||||
<NotificationsProvider />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -48,18 +48,17 @@
|
||||
-->
|
||||
<div class="py-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<router-link
|
||||
to="/"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Home class="mr-3 h-5 w-5" />
|
||||
Dashboard
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
<!-- Auth-required links -->
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<li>
|
||||
<router-link
|
||||
to="/"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors"
|
||||
active-class="bg-secondary">
|
||||
<Home class="mr-3 h-5 w-5" />
|
||||
Dashboard
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
to="/transactions"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
import { Bell } from "lucide-vue-next";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import "@/../styles/tailwind.css";
|
||||
|
||||
// Track notifications
|
||||
const notifications = ref<
|
||||
Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type?: "default" | "success" | "info" | "warning" | "error";
|
||||
duration?: number;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
// Auth store
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Handle incoming push messages
|
||||
function handlePushMessage(event: MessageEvent) {
|
||||
if (event.data && event.data.type === "PUSH_NOTIFICATION") {
|
||||
const { title, body, transactionId, type } = event.data;
|
||||
|
||||
// Add to notifications
|
||||
notifications.value.push({
|
||||
id: transactionId || Date.now().toString(),
|
||||
title: title || "New Notification",
|
||||
description: body || "You have a new notification",
|
||||
type:
|
||||
type === "income"
|
||||
? "success"
|
||||
: type === "expense"
|
||||
? "warning"
|
||||
: "info",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a notification
|
||||
function removeNotification(id: string) {
|
||||
const index = notifications.value.findIndex((n) => n.id === id);
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up message listener when authenticated
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (authenticated && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener("message", handlePushMessage);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Set up service worker message listener
|
||||
if (authStore.isAuthenticated && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.addEventListener("message", handlePushMessage);
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
if (navigator.serviceWorker) {
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"message",
|
||||
handlePushMessage,
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<Toast
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="notification.type ? `type-${notification.type}` : ''"
|
||||
@close="removeNotification(notification.id)">
|
||||
<div class="flex items-center gap-2">
|
||||
<Bell class="h-5 w-5" />
|
||||
<ToastTitle>{{ notification.title }}</ToastTitle>
|
||||
</div>
|
||||
<ToastDescription>{{ notification.description }}</ToastDescription>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
<!--
|
||||
<style scoped>
|
||||
.type-success {
|
||||
@apply border-green-500 bg-green-50;
|
||||
}
|
||||
.type-warning {
|
||||
@apply border-amber-500 bg-amber-50;
|
||||
}
|
||||
.type-error {
|
||||
@apply border-red-500 bg-red-50;
|
||||
}
|
||||
.type-info {
|
||||
@apply border-blue-500 bg-blue-50;
|
||||
}
|
||||
</style> -->
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
label: string,
|
||||
data: number[],
|
||||
backgroundColor?: string,
|
||||
borderColor?: string,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: 'rgba(200, 200, 200, 0.2)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-chart">
|
||||
<Bar
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
data: number[],
|
||||
backgroundColor?: string[],
|
||||
borderColor?: string[],
|
||||
circumference?: number,
|
||||
rotation?: number,
|
||||
cutout?: string,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
cutout: '75%'
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doughnut-chart">
|
||||
<Doughnut
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doughnut-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { Pie } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
} from 'chart.js'
|
||||
import { computed } from 'vue';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
CategoryScale
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
chartData: {
|
||||
labels: string[],
|
||||
datasets: Array<{
|
||||
data: number[],
|
||||
backgroundColor?: string[],
|
||||
borderColor?: string[],
|
||||
hoverOffset?: number,
|
||||
borderWidth?: number
|
||||
}>
|
||||
},
|
||||
chartOptions?: any
|
||||
}>()
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...props.chartOptions
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pie-chart">
|
||||
<Pie
|
||||
:data="chartData"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pie-chart {
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- src/components/ui/toast/Toast.vue -->
|
||||
<script lang="ts">
|
||||
export const positions = {
|
||||
"top-left": "top-0 left-0",
|
||||
"top-center": "top-0 left-1/2 -translate-x-1/2",
|
||||
"top-right": "top-0 right-0",
|
||||
"bottom-left": "bottom-0 left-0",
|
||||
"bottom-center": "bottom-0 left-1/2 -translate-x-1/2",
|
||||
"bottom-right": "bottom-0 right-0",
|
||||
}
|
||||
|
||||
export type ToastProps = {
|
||||
position?: keyof typeof positions
|
||||
class?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from "vue"
|
||||
|
||||
const props = defineProps<ToastProps>()
|
||||
const { position = "bottom-right", class: className } = toRefs(props)
|
||||
|
||||
const computedPosition = computed(() => positions[position.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-slate-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full bg-white dark:border-slate-800 dark:bg-slate-950',
|
||||
className,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- src/components/ui/toast/ToastClose.vue -->
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next"
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="absolute right-1 top-1 rounded-md p-1 text-slate-950/50 opacity-0 transition-opacity hover:text-slate-950 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 dark:text-slate-50/50 dark:hover:text-slate-50"
|
||||
@click="(event) => emit('click', event)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastDescription.vue -->
|
||||
<template>
|
||||
<div class="text-sm opacity-90">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastProvider.vue -->
|
||||
<template>
|
||||
<div class="relative">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- src/components/ui/toast/ToastTitle.vue -->
|
||||
<template>
|
||||
<div class="text-sm font-medium">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- src/components/ui/toast/ToastViewport.vue -->
|
||||
<script lang="ts">
|
||||
import { positions, type ToastProps } from "./Toast.vue"
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from "vue"
|
||||
|
||||
const props = withDefaults(defineProps<ToastProps>(), {
|
||||
position: "bottom-right",
|
||||
})
|
||||
const { position, class: className } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
:class="[
|
||||
'fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:flex-col md:max-w-[420px] gap-2',
|
||||
positions[position],
|
||||
className,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Toast } from "./Toast.vue"
|
||||
export { default as ToastClose } from "./ToastClose.vue"
|
||||
export { default as ToastDescription } from "./ToastDescription.vue"
|
||||
export { default as ToastProvider } from "./ToastProvider.vue"
|
||||
export { default as ToastTitle } from "./ToastTitle.vue"
|
||||
export { default as ToastViewport } from "./ToastViewport.vue"
|
||||
@@ -19,6 +19,9 @@ export async function registerServiceWorker() {
|
||||
console.log("Service worker active");
|
||||
}
|
||||
|
||||
// Ensure the service worker is ready before trying to subscribe
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error("Registration failed with error:", error);
|
||||
@@ -98,3 +101,132 @@ export function showBudgetAlert(percentage, category) {
|
||||
export function isOnline() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VAPID public key from the server
|
||||
* @returns {Promise<string>} The VAPID public key
|
||||
*/
|
||||
export async function getVapidPublicKey(): Promise<string | null> {
|
||||
try {
|
||||
console.log('Fetching VAPID public key...');
|
||||
const response = await fetch('/api/push/vapid-public-key', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch VAPID public key, status:', response.status);
|
||||
throw new Error('Failed to fetch VAPID public key');
|
||||
}
|
||||
|
||||
const key = await response.text();
|
||||
console.log('Received VAPID public key:', key.substring(0, 10) + '...');
|
||||
return key;
|
||||
} catch (error) {
|
||||
console.error('Error getting VAPID public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a base64 string to a Uint8Array for push subscription
|
||||
* @param {string} base64String The base64 string to convert
|
||||
* @returns {Uint8Array} The converted array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to push notifications
|
||||
* @returns {Promise<PushSubscription|null>} The push subscription
|
||||
*/
|
||||
export async function subscribeToPushNotifications(): Promise<PushSubscription | null> {
|
||||
try {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.log('Push notifications not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get service worker registration
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
console.log('Service worker is ready for push subscription');
|
||||
|
||||
// Check for existing subscription
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
if (existingSubscription) {
|
||||
console.log('Already subscribed to push notifications');
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
// Get VAPID key
|
||||
const vapidPublicKey = await getVapidPublicKey();
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Failed to get VAPID public key');
|
||||
}
|
||||
|
||||
console.log('Converting VAPID key to Uint8Array');
|
||||
// Convert key to array
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
|
||||
console.log('Subscribing to push notifications...');
|
||||
// Subscribe
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
});
|
||||
|
||||
console.log('Push subscription successful, registering with server');
|
||||
// Register with backend
|
||||
await registerSubscriptionWithServer(subscription);
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push notifications:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register push subscription with the backend
|
||||
* @param {PushSubscription} subscription The push subscription to register
|
||||
*/
|
||||
async function registerSubscriptionWithServer(subscription: PushSubscription): Promise<boolean> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/push/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ subscription })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to register subscription with server');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error registering subscription with server:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -4,13 +4,29 @@ import "../styles/transitions.css";
|
||||
import "../styles/style.css";
|
||||
import App from "./App.vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { registerServiceWorker } from "./lib/pwa-utils";
|
||||
import { registerServiceWorker, subscribeToPushNotifications } from "./lib/pwa-utils";
|
||||
|
||||
// Register service worker for PWA functionality
|
||||
if (import.meta.env.PROD) {
|
||||
registerServiceWorker();
|
||||
// Initialize PWA functionality
|
||||
async function initPWA() {
|
||||
// Register service worker
|
||||
const registration = await registerServiceWorker();
|
||||
|
||||
// Check if push notifications are enabled
|
||||
if (registration &&
|
||||
localStorage.getItem("notificationsEnabled") === "true" &&
|
||||
Notification.permission === "granted") {
|
||||
try {
|
||||
await subscribeToPushNotifications();
|
||||
console.log("Push notifications subscription successful");
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to push notifications:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register service worker and initialize PWA
|
||||
initPWA();
|
||||
|
||||
const router = createRouter({
|
||||
// createWebHistory is like BrowserRouter in React Router
|
||||
history: createWebHistory(),
|
||||
@@ -20,6 +36,7 @@ const router = createRouter({
|
||||
// Dynamic imports for code splitting - similar to React.lazy
|
||||
// In React: const Dashboard = React.lazy(() => import('./pages/Dashboard'))
|
||||
component: () => import("./pages/Dashboard.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Login Page Component
|
||||
* -------------------
|
||||
* This is a page component that handles user authentication.
|
||||
* In React terms, this would be a container/page component with form handling logic.
|
||||
*/
|
||||
|
||||
// Import UI components - similar to importing from a UI library in React
|
||||
// These are shadcn components (Vue's equivalent of shadcn/ui for React)
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -30,9 +21,6 @@
|
||||
const isRegistering = ref(false);
|
||||
const email = ref("");
|
||||
|
||||
/**
|
||||
* Handle login form submission
|
||||
*/
|
||||
const login = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
errorMessage.value = "Please enter both username and password";
|
||||
@@ -89,26 +77,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle between login and register forms
|
||||
*/
|
||||
const toggleRegistration = () => {
|
||||
isRegistering.value = !isRegistering.value;
|
||||
errorMessage.value = "";
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<!--
|
||||
Main Template
|
||||
------------
|
||||
In React, this would all be JSX returned by the component function.
|
||||
|
||||
Key differences:
|
||||
- Vue uses <template> section instead of return statement with JSX
|
||||
- Vue uses @ for events (e.g., @submit) instead of React's camelCase (e.g., onSubmit)
|
||||
- Vue uses : for props (e.g., :disabled) instead of React's {} (e.g., disabled={value})
|
||||
- Vue uses v-for, v-if instead of React's .map() and && or ternary operators
|
||||
-->
|
||||
<PageLayout header="Login">
|
||||
<!-- Layout structure (similar to React) -->
|
||||
<div class="flex justify-center items-center h-full">
|
||||
|
||||
+145
-20
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, reactive } from "vue";
|
||||
import PageLayout from "@/components/PageLayout.vue";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,7 +11,10 @@
|
||||
import { useTransactionsStore } from "@/stores/transactions";
|
||||
import { useCategoriesStore } from "@/stores/categories";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { PieChart, BarChart, Calendar } from "lucide-vue-next";
|
||||
import { PieChart as PieChartIcon, BarChart as BarChartIcon, Calendar } from "lucide-vue-next";
|
||||
import PieChart from "@/components/charts/PieChart.vue";
|
||||
import BarChart from "@/components/charts/BarChart.vue";
|
||||
import DoughnutChart from "@/components/charts/DoughnutChart.vue";
|
||||
|
||||
// Initialize stores
|
||||
const transactionsStore = useTransactionsStore();
|
||||
@@ -165,6 +168,78 @@
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Chart data for monthly trend
|
||||
const monthlyTrendChartData = computed(() => {
|
||||
return {
|
||||
labels: monthlyTrend.value.map(item => item.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Income',
|
||||
data: monthlyTrend.value.map(item => item.income),
|
||||
backgroundColor: 'rgba(75, 192, 75, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 75, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Expenses',
|
||||
data: monthlyTrend.value.map(item => item.expenses),
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.6)',
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Savings',
|
||||
data: monthlyTrend.value.map(item => item.savings),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for category breakdown
|
||||
const categoryChartData = computed(() => {
|
||||
return {
|
||||
labels: categoryBreakdown.value.map(item => item.name),
|
||||
datasets: [{
|
||||
data: categoryBreakdown.value.map(item => item.amount),
|
||||
backgroundColor: categoryBreakdown.value.map(item => item.color),
|
||||
borderColor: categoryBreakdown.value.map(item => item.color),
|
||||
hoverOffset: 4,
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// Budget usage doughnut chart data
|
||||
const budgetChartData = computed(() => {
|
||||
// For this example, we'll use a mock budget value of $2000
|
||||
// In a real app, you'd get this from user settings or another store
|
||||
const targetBudget = 2000;
|
||||
const spent = monthlySummary.value.expenses;
|
||||
const remaining = Math.max(0, targetBudget - spent);
|
||||
const percentage = Math.min(100, Math.round((spent / targetBudget) * 100));
|
||||
|
||||
let statusColor = '#22c55e'; // green for < 70%
|
||||
if (percentage >= 90) {
|
||||
statusColor = '#ef4444'; // red for >= 90%
|
||||
} else if (percentage >= 70) {
|
||||
statusColor = '#f59e0b'; // amber for >= 70%
|
||||
}
|
||||
|
||||
return {
|
||||
labels: ['Spent', 'Remaining'],
|
||||
datasets: [{
|
||||
data: [spent, remaining],
|
||||
backgroundColor: [statusColor, '#e5e7eb'],
|
||||
borderColor: ['transparent', 'transparent'],
|
||||
borderWidth: 0,
|
||||
cutout: '80%'
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// Function to switch between report types
|
||||
const setReportType = (type) => {
|
||||
@@ -186,13 +261,13 @@
|
||||
<Button
|
||||
:variant="activeReport === 'overview' ? 'default' : 'outline'"
|
||||
@click="setReportType('overview')">
|
||||
<PieChart class="mr-2 h-4 w-4" />
|
||||
<PieChartIcon class="mr-2 h-4 w-4" />
|
||||
Monthly Overview
|
||||
</Button>
|
||||
<Button
|
||||
:variant="activeReport === 'byCategory' ? 'default' : 'outline'"
|
||||
@click="setReportType('byCategory')">
|
||||
<BarChart class="mr-2 h-4 w-4" />
|
||||
<BarChartIcon class="mr-2 h-4 w-4" />
|
||||
Spending by Category
|
||||
</Button>
|
||||
<Button
|
||||
@@ -282,17 +357,53 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Placeholder for budget comparison chart -->
|
||||
<!-- Budget comparison chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Budget Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="h-40 flex items-center justify-center">
|
||||
<p class="text-gray-500 text-center">
|
||||
Budget comparison visualization will appear here.
|
||||
<br /><small>Set your budget targets in Settings</small>
|
||||
</p>
|
||||
<div class="relative h-60">
|
||||
<DoughnutChart
|
||||
:chartData="budgetChartData"
|
||||
:chartOptions="{
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed;
|
||||
const label = context.label;
|
||||
return `${label}: ${formatCurrency(value)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<!-- Center text with percentage -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-4xl font-bold">
|
||||
{{ Math.min(100, Math.round((monthlySummary.expenses / 2000) * 100)) }}%
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">of monthly budget</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between text-sm">
|
||||
<div>
|
||||
<div class="text-gray-500">Monthly Budget</div>
|
||||
<div class="font-medium">{{ formatCurrency(2000) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500">Spent</div>
|
||||
<div class="font-medium">{{ formatCurrency(monthlySummary.expenses) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500">Remaining</div>
|
||||
<div class="font-medium">{{ formatCurrency(Math.max(0, 2000 - monthlySummary.expenses)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -312,11 +423,25 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Placeholder for pie chart -->
|
||||
<div class="h-40 flex items-center justify-center">
|
||||
<p class="text-gray-500 text-center">
|
||||
Category distribution chart will appear here.
|
||||
</p>
|
||||
<!-- Pie chart for category spending -->
|
||||
<div class="h-72">
|
||||
<PieChart
|
||||
:chartData="categoryChartData"
|
||||
:chartOptions="{
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const value = context.parsed;
|
||||
const label = context.label;
|
||||
const percentage = categoryBreakdown.value[context.dataIndex]?.percentage;
|
||||
return `${label}: ${formatCurrency(value)} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category breakdown table -->
|
||||
@@ -349,11 +474,11 @@
|
||||
<CardTitle>Monthly Income & Expenses ({{ selectedYear }})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Placeholder for bar chart -->
|
||||
<div class="h-60 flex items-center justify-center mb-6">
|
||||
<p class="text-gray-500 text-center">
|
||||
Monthly trend chart will appear here.
|
||||
</p>
|
||||
<!-- Monthly trend bar chart -->
|
||||
<div class="h-80">
|
||||
<BarChart
|
||||
:chartData="monthlyTrendChartData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Monthly data table -->
|
||||
|
||||
@@ -19,8 +19,14 @@
|
||||
Moon,
|
||||
Sun,
|
||||
Smartphone,
|
||||
AlertCircle,
|
||||
} from "lucide-vue-next";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
requestNotificationPermission,
|
||||
registerServiceWorker,
|
||||
subscribeToPushNotifications
|
||||
} from "@/lib/pwa-utils";
|
||||
|
||||
// Initialize store
|
||||
const authStore = useAuthStore();
|
||||
@@ -40,6 +46,9 @@
|
||||
const isOnline = ref(navigator.onLine);
|
||||
const serviceWorkerRegistered = ref(false);
|
||||
const pushPermission = ref("default");
|
||||
const pushSubscribed = ref(false);
|
||||
const isSubscribing = ref(false);
|
||||
const subscriptionError = ref("");
|
||||
|
||||
// Check if PWA is installed
|
||||
onMounted(async () => {
|
||||
@@ -67,6 +76,13 @@
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
serviceWorkerRegistered.value = registrations.length > 0;
|
||||
|
||||
// Check if already subscribed to push
|
||||
if (serviceWorkerRegistered.value) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
pushSubscribed.value = !!subscription;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking service worker:", error);
|
||||
}
|
||||
@@ -81,6 +97,9 @@
|
||||
if (permission === "granted") {
|
||||
notificationsEnabled.value = true;
|
||||
pushPermission.value = "granted";
|
||||
|
||||
// Subscribe to push notifications if permission granted
|
||||
subscribeToPushService();
|
||||
} else {
|
||||
notificationsEnabled.value = false;
|
||||
pushPermission.value = permission;
|
||||
@@ -88,6 +107,10 @@
|
||||
}
|
||||
} else {
|
||||
notificationsEnabled.value = !notificationsEnabled.value;
|
||||
|
||||
if (notificationsEnabled.value && !pushSubscribed.value) {
|
||||
subscribeToPushService();
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
@@ -96,6 +119,49 @@
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to push notification service
|
||||
const subscribeToPushService = async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
subscriptionError.value = "You must be signed in to receive notifications";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubscribing.value = true;
|
||||
subscriptionError.value = "";
|
||||
|
||||
try {
|
||||
// Ensure service worker is registered
|
||||
if (!serviceWorkerRegistered.value) {
|
||||
await registerServiceWorker();
|
||||
serviceWorkerRegistered.value = true;
|
||||
}
|
||||
|
||||
// Request permission if not already granted
|
||||
if (Notification.permission !== "granted") {
|
||||
const permission = await requestNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
pushPermission.value = permission;
|
||||
throw new Error("Notification permission denied");
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const subscription = await subscribeToPushNotifications();
|
||||
if (subscription) {
|
||||
pushSubscribed.value = true;
|
||||
notificationsEnabled.value = true;
|
||||
localStorage.setItem("notificationsEnabled", "true");
|
||||
} else {
|
||||
throw new Error("Failed to subscribe to push notifications");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error subscribing to push notifications:", error);
|
||||
subscriptionError.value = (error as Error).message || "Failed to subscribe to push notifications";
|
||||
} finally {
|
||||
isSubscribing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle dark mode
|
||||
const toggleDarkMode = () => {
|
||||
@@ -172,7 +238,7 @@
|
||||
<div class="space-y-0.5">
|
||||
<Label>Push Notifications</Label>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enable budget alerts and reminders
|
||||
Enable transaction alerts and reminders
|
||||
</div>
|
||||
<div
|
||||
v-if="pushPermission === 'denied'"
|
||||
@@ -180,13 +246,25 @@
|
||||
Permission denied. Please enable notifications in your browser
|
||||
settings.
|
||||
</div>
|
||||
<div
|
||||
v-if="subscriptionError"
|
||||
class="text-xs text-red-500 mt-1 flex items-center">
|
||||
<AlertCircle class="h-3 w-3 mr-1" />
|
||||
{{ subscriptionError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="pushSubscribed"
|
||||
class="text-xs text-green-600 mt-1">
|
||||
Subscribed to transaction notifications
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@click="toggleNotifications"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
:disabled="pushPermission === 'denied'">
|
||||
:disabled="pushPermission === 'denied' || isSubscribing"
|
||||
:loading="isSubscribing">
|
||||
<Bell class="h-4 w-4 mr-2" />
|
||||
{{ notificationsEnabled ? "Disable" : "Enable" }}
|
||||
</Button>
|
||||
@@ -261,6 +339,16 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Push Notifications</p>
|
||||
<span
|
||||
:class="
|
||||
pushSubscribed ? 'text-green-600' : 'text-gray-600'
|
||||
">
|
||||
{{ pushSubscribed ? "Subscribed" : "Not Subscribed" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPWAInstalled" class="mt-4">
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
Install this app on your device for better performance and
|
||||
|
||||
+66
-2
@@ -14,7 +14,72 @@ export default defineConfig({
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
VitePWA({ registerType: "autoUpdate", devOptions: { enabled: true } }),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: { enabled: true },
|
||||
manifest: {
|
||||
name: "Budget Manager",
|
||||
short_name: "BudgetMgr",
|
||||
description: "Track and manage your finances",
|
||||
theme_color: "#ffffff",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192px.jpg",
|
||||
sizes: "192x192",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512px.jpg",
|
||||
sizes: "512x512",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,jpg}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "google-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "gstatic-fonts-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/(?!.*auth).*/i, // All API routes except auth
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 // 1 hour
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -26,7 +91,6 @@ export default defineConfig({
|
||||
"/api": {
|
||||
target: "http://localhost:3000", // Replace with your API server URL
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
port: 3001, // Replace with your desired port
|
||||
|
||||
Reference in New Issue
Block a user