push notification
fix implemented
This commit is contained in:
parent
0bbf2ab56f
commit
57864e00da
@ -6,12 +6,17 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PushService } from './push.service';
|
import { PushService } from './push.service';
|
||||||
import { SubscribeDto, UnsubscribeDto } from './push.dto';
|
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
|
||||||
import { Public } from '../auth/public.decorator';
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
@Controller('push')
|
@Controller('push')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
export class PushController {
|
export class PushController {
|
||||||
constructor(private readonly pushService: PushService) {}
|
constructor(private readonly pushService: PushService) {}
|
||||||
|
|
||||||
@ -25,6 +30,12 @@ export class PushController {
|
|||||||
@Post('subscribe')
|
@Post('subscribe')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
|
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
|
||||||
|
console.log('Received push subscription:', {
|
||||||
|
endpoint: dto.endpoint?.substring(0, 50) + '...',
|
||||||
|
hasP256dh: !!dto.p256dh,
|
||||||
|
hasAuth: !!dto.auth,
|
||||||
|
userId: dto.userId,
|
||||||
|
});
|
||||||
await this.pushService.subscribe(dto);
|
await this.pushService.subscribe(dto);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@ -35,4 +46,24 @@ export class PushController {
|
|||||||
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
|
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
|
||||||
await this.pushService.unsubscribe(dto);
|
await this.pushService.unsubscribe(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@Get('stats')
|
||||||
|
getStats(): Promise<{ totalSubscribers: number }> {
|
||||||
|
return this.pushService.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
|
@Post('send')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async sendNotification(
|
||||||
|
@Body() dto: SendNotificationDto,
|
||||||
|
): Promise<{ sent: number; failed: number }> {
|
||||||
|
return this.pushService.sendToAll({
|
||||||
|
title: dto.title,
|
||||||
|
body: dto.body,
|
||||||
|
url: dto.url,
|
||||||
|
tag: 'admin-notification',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class SubscribeDto {
|
export class SubscribeDto {
|
||||||
@IsUrl()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|
||||||
@ -19,7 +19,21 @@ export class SubscribeDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UnsubscribeDto {
|
export class UnsubscribeDto {
|
||||||
@IsUrl()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SendNotificationDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -49,11 +49,15 @@ export class PushService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
||||||
|
this.logger.log('Subscribing push notification...');
|
||||||
|
this.logger.debug(`Endpoint: ${dto.endpoint?.substring(0, 50)}...`);
|
||||||
|
|
||||||
const existing = await this.subscriptionRepo.findOne({
|
const existing = await this.subscriptionRepo.findOne({
|
||||||
where: { endpoint: dto.endpoint },
|
where: { endpoint: dto.endpoint },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
this.logger.log('Subscription already exists, updating...');
|
||||||
if (dto.userId && existing.userId !== dto.userId) {
|
if (dto.userId && existing.userId !== dto.userId) {
|
||||||
existing.userId = dto.userId;
|
existing.userId = dto.userId;
|
||||||
return this.subscriptionRepo.save(existing);
|
return this.subscriptionRepo.save(existing);
|
||||||
@ -61,6 +65,7 @@ export class PushService implements OnModuleInit {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log('Creating new subscription...');
|
||||||
const subscription = this.subscriptionRepo.create({
|
const subscription = this.subscriptionRepo.create({
|
||||||
endpoint: dto.endpoint,
|
endpoint: dto.endpoint,
|
||||||
p256dh: dto.p256dh,
|
p256dh: dto.p256dh,
|
||||||
@ -68,7 +73,9 @@ export class PushService implements OnModuleInit {
|
|||||||
userId: dto.userId ?? null,
|
userId: dto.userId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.subscriptionRepo.save(subscription);
|
const saved = await this.subscriptionRepo.save(subscription);
|
||||||
|
this.logger.log(`Subscription saved with ID: ${saved.id}`);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
|
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
|
||||||
@ -170,4 +177,9 @@ export class PushService implements OnModuleInit {
|
|||||||
tag: `live-blog-${blogSlug}`,
|
tag: `live-blog-${blogSlug}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStats(): Promise<{ totalSubscribers: number }> {
|
||||||
|
const totalSubscribers = await this.subscriptionRepo.count();
|
||||||
|
return { totalSubscribers };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
166
frontend/src/components/admin/PushNotificationManager.tsx
Normal file
166
frontend/src/components/admin/PushNotificationManager.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { usePushStats, useSendPushNotification } from '@/queries/push';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function PushNotificationManager() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: stats, isLoading: loadingStats } = usePushStats();
|
||||||
|
const sendMutation = useSendPushNotification();
|
||||||
|
|
||||||
|
const handleSendTest = async () => {
|
||||||
|
if (!title.trim() || !body.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendMutation.mutateAsync({
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim(),
|
||||||
|
url: url.trim() || undefined,
|
||||||
|
});
|
||||||
|
setResult(result);
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 5000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = title.trim().length > 0 && body.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-brutal bg-card p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Bell className="h-6 w-6 text-accent" />
|
||||||
|
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="border-brutal-sm bg-background p-5 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
|
Subscribers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadingStats ? (
|
||||||
|
<div className="text-4xl font-display animate-pulse">...</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-5xl font-display">
|
||||||
|
{stats?.totalSubscribers ?? 0}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-body text-muted-foreground mt-2">
|
||||||
|
Active push subscribers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="border-brutal-sm bg-background p-5">
|
||||||
|
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
|
Send Notification
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter notification title..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Body
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Enter notification message..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
|
||||||
|
rows={3}
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="/article/slug or /live-blogs/slug"
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 pt-2">
|
||||||
|
<div className="text-xs font-body text-muted-foreground">
|
||||||
|
{title.length}/50 chars • {body.length}/150 chars
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="brutalAccent"
|
||||||
|
onClick={handleSendTest}
|
||||||
|
disabled={!isValid || sendMutation.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuccess && result && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-body text-sm font-medium">
|
||||||
|
Notification sent!
|
||||||
|
</p>
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
Delivered: {result.sent} • Failed: {result.failed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMutation.isError && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||||
|
<p className="font-body text-sm">
|
||||||
|
Failed to send notification. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
<strong>Note:</strong> Notifications are sent to all subscribers with
|
||||||
|
push enabled. Use sparingly to avoid spam. Articles and live blog
|
||||||
|
updates are automatically pushed when published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { mk } from 'date-fns/locale';
|
import { mk } from 'date-fns/locale';
|
||||||
|
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
|
||||||
|
|
||||||
export function AdminDashboardComponent() {
|
export function AdminDashboardComponent() {
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{liveBlogs.filter(b => b.status === 'live').length || 0}
|
{liveBlogs.filter((b) => b.status === 'live').length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Активни live блогови
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{articles.filter(a => a.status === 'published').length || 0}
|
{articles.filter((a) => a.status === 'published').length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Објавени написи
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{liveBlogs.filter(b => b.isPinned).length || 0}
|
{liveBlogs.filter((b) => b.isPinned).length || 0}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Закачени live блогови
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-brutal bg-card p-5">
|
<div className="border-brutal bg-card p-5">
|
||||||
<div className="text-4xl font-display">
|
<div className="text-4xl font-display">
|
||||||
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
|
||||||
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
(articles.reduce((sum, a) => sum + a.views, 0) || 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p>
|
<p className="text-xs font-body text-muted-foreground mt-1">
|
||||||
|
Вкупни прегледи
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!showArchived && <PushNotificationManager />}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="border-brutal bg-card">
|
<div className="border-brutal bg-card">
|
||||||
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
|
||||||
|
|||||||
@ -827,3 +827,41 @@ export async function getUserReaction(
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push Notification Admin Types
|
||||||
|
export interface PushStats {
|
||||||
|
totalSubscribers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendNotificationResult {
|
||||||
|
sent: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendNotificationDto {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push Notification Admin API Functions
|
||||||
|
export async function fetchPushStats(): Promise<PushStats> {
|
||||||
|
const response = await authFetch(`${API_BASE_URL}/push/stats`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch push stats');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushNotification(
|
||||||
|
dto: SendNotificationDto,
|
||||||
|
): Promise<SendNotificationResult> {
|
||||||
|
const response = await authFetch(`${API_BASE_URL}/push/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send push notification');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|||||||
20
frontend/src/queries/push.ts
Normal file
20
frontend/src/queries/push.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as api from '../lib/api';
|
||||||
|
|
||||||
|
export function usePushStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['push', 'stats'],
|
||||||
|
queryFn: api.fetchPushStats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSendPushNotification() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.sendPushNotification,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
pwa/dev-dist/registerSW.js
Normal file
1
pwa/dev-dist/registerSW.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
101
pwa/dev-dist/sw.js
Normal file
101
pwa/dev-dist/sw.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-d975e299'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "registerSW.js",
|
||||||
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
|
}, {
|
||||||
|
"url": "index.html",
|
||||||
|
"revision": "0.ek3n41mv3u8"
|
||||||
|
}], {});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
allowlist: [/^\/$/]
|
||||||
|
}));
|
||||||
|
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.StaleWhileRevalidate({
|
||||||
|
"cacheName": "api-cache",
|
||||||
|
plugins: [new workbox.ExpirationPlugin({
|
||||||
|
maxEntries: 100,
|
||||||
|
maxAgeSeconds: 86400
|
||||||
|
}), new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
|
||||||
|
}));
|
||||||
4612
pwa/dev-dist/workbox-d975e299.js
Normal file
4612
pwa/dev-dist/workbox-d975e299.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -32,10 +32,19 @@ export function NotificationBanner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
|
console.log('handleSubscribe called');
|
||||||
|
console.log('isSupported:', isSupported);
|
||||||
|
console.log('isSubscribed:', isSubscribed);
|
||||||
|
|
||||||
|
try {
|
||||||
const success = await subscribe();
|
const success = await subscribe();
|
||||||
|
console.log('subscribe() returned:', success);
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('handleSubscribe error:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible || isSubscribed || isDismissed) {
|
if (!isVisible || isSubscribed || isDismissed) {
|
||||||
|
|||||||
@ -40,13 +40,20 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
|||||||
const [permissionState, setPermissionState] = useState(getInitialPermission);
|
const [permissionState, setPermissionState] = useState(getInitialPermission);
|
||||||
const hasCheckedRef = useRef(false);
|
const hasCheckedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('usePushNotifications - isSupported:', checkPushSupport());
|
||||||
|
console.log('usePushNotifications - permissionState:', getInitialPermission());
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSupported || hasCheckedRef.current) return;
|
if (!isSupported || hasCheckedRef.current) return;
|
||||||
hasCheckedRef.current = true;
|
hasCheckedRef.current = true;
|
||||||
|
|
||||||
|
console.log('Checking existing push subscription...');
|
||||||
navigator.serviceWorker.ready
|
navigator.serviceWorker.ready
|
||||||
.then((registration) => registration.pushManager.getSubscription())
|
.then((registration) => registration.pushManager.getSubscription())
|
||||||
.then((subscription) => {
|
.then((subscription) => {
|
||||||
|
console.log('Existing subscription:', subscription);
|
||||||
setIsSubscribed(!!subscription);
|
setIsSubscribed(!!subscription);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@ -71,6 +78,7 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
|||||||
try {
|
try {
|
||||||
const permission = await requestPermission();
|
const permission = await requestPermission();
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
|
console.log('Push permission denied');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -82,19 +90,58 @@ export function usePushNotifications(): UsePushNotificationsReturn {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
console.log('Got VAPID public key, subscribing...');
|
||||||
|
|
||||||
|
// Check if service worker is already ready
|
||||||
|
console.log('Checking service worker status...');
|
||||||
|
const swController = navigator.serviceWorker.controller;
|
||||||
|
console.log('Service worker controller:', swController);
|
||||||
|
|
||||||
|
// Wait for service worker to be ready with timeout
|
||||||
|
const registration = await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise<ServiceWorkerRegistration>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Service worker ready timeout after 10s'));
|
||||||
|
}, 10000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
console.log('Service worker ready:', registration);
|
||||||
|
|
||||||
|
if (!registration.pushManager) {
|
||||||
|
console.error('PushManager not available in service worker registration');
|
||||||
|
setIsLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let subscription = await registration.pushManager.getSubscription();
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
|
console.log('Creating new push subscription...');
|
||||||
subscription = await registration.pushManager.subscribe({
|
subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
});
|
});
|
||||||
|
console.log('Push subscription created');
|
||||||
|
} else {
|
||||||
|
console.log('Existing push subscription found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subJson = subscription.toJSON() as PushSubscriptionData;
|
const subJson = subscription.toJSON();
|
||||||
const success = await subscribeToPush(subJson);
|
console.log('Subscription JSON:', subJson);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!subJson.endpoint ||
|
||||||
|
!subJson.keys?.p256dh ||
|
||||||
|
!subJson.keys?.auth
|
||||||
|
) {
|
||||||
|
console.error('Invalid subscription data:', subJson);
|
||||||
|
setIsLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await subscribeToPush(subJson as PushSubscriptionData);
|
||||||
|
console.log('Subscribe to push result:', success);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsSubscribed(true);
|
setIsSubscribed(true);
|
||||||
|
|||||||
@ -30,18 +30,37 @@ export async function subscribeToPush(
|
|||||||
subscription: PushSubscriptionData,
|
subscription: PushSubscriptionData,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
console.log('Sending subscription to server:', {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
hasKeys: !!subscription.keys,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Payload:', payload);
|
||||||
|
console.log('API URL:', `${API_BASE_URL}/push/subscribe`);
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
p256dh: subscription.keys.p256dh,
|
|
||||||
auth: subscription.keys.auth,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
return response.ok;
|
|
||||||
|
console.log('Subscribe response status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Subscribe failed:', errorText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error subscribing to push:', error);
|
console.error('Error subscribing to push:', error);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -5,12 +5,30 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { router } from './routes'
|
import { router } from './routes'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { initializeTheme } from './lib/theme'
|
import { initializeTheme } from './lib/theme'
|
||||||
import 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
|
||||||
initializeTheme()
|
initializeTheme()
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
const updateSW = registerSW({
|
||||||
|
onNeedRefresh() {
|
||||||
|
if (confirm('New content available. Reload?')) {
|
||||||
|
updateSW(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log('App ready to work offline')
|
||||||
|
},
|
||||||
|
onRegistered(registration) {
|
||||||
|
console.log('SW Registered:', registration?.scope)
|
||||||
|
},
|
||||||
|
onRegisterError(error) {
|
||||||
|
console.error('SW registration error:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user