Merge commit '48f46ec9fe683c0befdd19f5bc5654c327133bed' into production

This commit is contained in:
Dobromir Popov
2024-05-07 18:58:45 +00:00
59 changed files with 2289 additions and 897 deletions

8
.env
View File

@ -2,7 +2,7 @@
# HOST=localhost # HOST=localhost
# PORT=3003 # PORT=3003
# NEXT_PUBLIC_PUBLIC_URL=http://localhost:3003 # NEXT_PUBLIC_PUBLIC_URL=http://localhost:3003
ENV_ENV='.env'
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32 # Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58 NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
@ -10,6 +10,7 @@ NODE_ENV=development
# mysql. ONLY THIS ENV is respected when generating/applying migrations in prisma # mysql. ONLY THIS ENV is respected when generating/applying migrations in prisma
DATABASE=mysql://cart:cartpw@localhost:3306/cart DATABASE=mysql://cart:cartpw@localhost:3306/cart
# DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev # DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
NEXT_PUBLIC_PUBLIC_URL=https://localhost:3003
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София # // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716 # // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
@ -69,5 +70,6 @@ MAILTRAP_PASS=c7bc05f171c96c
TELEGRAM_BOT=false TELEGRAM_BOT=false
TELEGRAM_BOT_TOKEN=7050075088:AAH6VRpNCyQd9x9sW6CLm6q0q4ibUgYBfnM TELEGRAM_BOT_TOKEN=7050075088:AAH6VRpNCyQd9x9sW6CLm6q0q4ibUgYBfnM
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BGxXJ0jdsQ4ihE7zp8mxrBO-QPSjeEtO9aCtPoMTuxc1VLW0OfRIt-DYinK9ekjTl2w-j0eQbeprIyBCpmmfciI WEB_PUSH_EMAIL=mwitnessing@gmail.com
VAPID_PRIVATE_KEY=VXHu2NgcyM4J4w3O4grkS_0yLwWHCvVKDJexyBjqgx0 NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY=BGxXJ0jdsQ4ihE7zp8mxrBO-QPSjeEtO9aCtPoMTuxc1VLW0OfRIt-DYinK9ekjTl2w-j0eQbeprIyBCpmmfciI
WEB_PUSH_PRIVATE_KEY=VXHu2NgcyM4J4w3O4grkS_0yLwWHCvVKDJexyBjqgx0

View File

@ -1,5 +1,6 @@
NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert # NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
ENV_ENV=.env.development
PROTOCOL=https PROTOCOL=https
PORT=3003 PORT=3003
HOST=localhost HOST=localhost

View File

@ -0,0 +1,13 @@
# .ENV for vscode server .11 dev server #
NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
NODE_ENV=development
PROTOCOL=http
PORT=3003
HOST=cart.d-popov.com
NEXT_PUBLIC_PUBLIC_URL=https://cart.d-popov.com
DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
EMAIL_SENDER='"ССОМ [ТЕСТ] " <mwitnessing@gmail.com>'

View File

@ -1,3 +1,5 @@
# .ENV for vscode server .11 dev server #
NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert # NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
NODE_ENV=development NODE_ENV=development

View File

@ -1,4 +1,6 @@
# trying to run .env.test.staging did not work... falling back to .env.test
NODE_ENV=test NODE_ENV=test
ENV_ENV=test
PROTOCOL=http PROTOCOL=http
HOST=staging.mwitnessing.com HOST=staging.mwitnessing.com
@ -6,19 +8,4 @@ PORT=
NEXT_PUBLIC_PUBLIC_URL=https://staging.mwitnessing.com NEXT_PUBLIC_PUBLIC_URL=https://staging.mwitnessing.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32 # Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638 NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia_demo:dwxhns9p9vp248@mariadb:3306/jwpwsofia_demo
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
# EMAIL_SERVICE=mailtrap
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=live.smtp.mailtrap.io
# MAILTRAP_USER=api
# MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d

26
.env.test.staging Normal file
View File

@ -0,0 +1,26 @@
# trying to run .env.test.staging did not work... falling back to .env.test
NODE_ENV=test
ENV_ENV=test.staging
PROTOCOL=http
HOST=staging.mwitnessing.com
PORT=
NEXT_PUBLIC_PUBLIC_URL=https://staging.mwitnessing.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia_demo:dwxhns9p9vp248@mariadb:3306/jwpwsofia_demo
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
# EMAIL_SERVICE=mailtrap
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=live.smtp.mailtrap.io
# MAILTRAP_USER=api
# MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d

2
.gitignore vendored
View File

@ -33,6 +33,6 @@ next-cart-app.zip
!public/uploads/thumb/ !public/uploads/thumb/
certificates certificates
content/output/* content/output/*
baseUrl.txt
public/content/output/* public/content/output/*
public/content/output/shifts 2024.1.json public/content/output/shifts 2024.1.json
!public/content/uploads/*

12
.vscode/launch.json vendored
View File

@ -41,7 +41,7 @@
"type": "node-terminal" "type": "node-terminal"
}, },
{ {
"name": "Run conda nodemon (DEV)", "name": "Conda debug (DB)",
"request": "launch", "request": "launch",
"type": "node-terminal", "type": "node-terminal",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
@ -50,6 +50,16 @@
"APP_ENV": "development.popov" "APP_ENV": "development.popov"
} }
}, },
{
"name": "Conda run (DB)",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}",
"command": "conda activate node && npm run start-env",
"env": {
"APP_ENV": "development.devserver"
}
},
{ {
"name": "Run conda npm TEST", "name": "Run conda npm TEST",
"request": "launch", "request": "launch",

View File

@ -1,10 +1,11 @@
version: "3" version: "3"
services: services:
nextjs-app: # https://sofia.mwitnessing.com/ nextjs-app: # https://sofia.mwhitnessing.com/
hostname: jwpw-app-staging # jwpw-nextjs-app-1 hostname: jwpw-app-staging # jwpw-nextjs-app-1
image: docker.d-popov.com/jwpw:latest image: docker.d-popov.com/jwpw:latest
volumes: volumes:
- /mnt/docker_volumes/pw-demo/app/public/content/uploads/:/app/public/content/uploads - /mnt/docker_volumes/pw-demo/app/public/content/uploads/:/app/public/content/uploads
- /mnt/docker_volumes/pw-demo/app/logs:/app/logs
environment: environment:
- APP_ENV=test - APP_ENV=test
- NODE_ENV=test - NODE_ENV=test
@ -14,12 +15,13 @@ services:
- GIT_BRANCH=main - GIT_BRANCH=main
- GIT_USERNAME=deploy - GIT_USERNAME=deploy
- GIT_PASSWORD=L3Kr2R438u4F7 - GIT_PASSWORD=L3Kr2R438u4F7
command: sh -c " cd /app && npm install && npx next build && npm run nodeenv; tail -f /dev/null" command: sh -c " cd /app && npm install && npx next build && npm run start-env; tail -f /dev/null"
tty: true tty: true
stdin_open: true stdin_open: true
restart: always restart: always
networks: networks:
- infrastructure_default - infrastructure_default
- default
mariadb: mariadb:
deploy: deploy:
replicas: 1 replicas: 1
@ -33,6 +35,11 @@ services:
MYSQL_DATABASE: jwpwsofia_demo MYSQL_DATABASE: jwpwsofia_demo
MYSQL_USER: jwpwsofia_demo MYSQL_USER: jwpwsofia_demo
MYSQL_PASSWORD: dwxhns9p9vp248 MYSQL_PASSWORD: dwxhns9p9vp248
adminer:
image: adminer
restart: always
ports:
- 5002:8080
networks: networks:
infrastructure_default: infrastructure_default:
external: true external: true

35
_deploy/maintenance.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="bg">
<!-- put that file in the web root of the maintenance container and rename it. We use nginx, so the path is /usr/share/nginx/html/index.html -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Поддръжка на сайта</title>
<style>
body {
height: 100vh;
margin: 0;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
background-color: #f4f4f4;
color: #333;
text-align: center;
}
h1 {
font-size: 24px;
}
p {
font-size: 16px;
}
</style>
</head>
<body>
<div>
<h1>Поддръжка и обновления!</h1>
<p>Съжаляваме за неудобството, но в момента извършваме поддръжка на сайта. Ще се върнем онлайн скоро!</p>
<p>&mdash; Екипът на ССОМ: Специално Свидетелстване на Обществени Места - София</p>
</div>
</body>
</html>

View File

@ -6,7 +6,7 @@ services:
volumes: volumes:
- /mnt/docker_volumes/maintenance:/usr/share/nginx/html - /mnt/docker_volumes/maintenance:/usr/share/nginx/html
ports: ports:
- "81:80" - "3010:80"
environment: environment:
- NGINX_HOST=nginx - NGINX_HOST=nginx
- NGINX_PORT=80 - NGINX_PORT=80

View File

@ -135,6 +135,12 @@ npx prisma migrate resolve --applied 20240201214719_assignment_add_repeat_freque
npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work
## ---------------------- import database --------------------------------- ##
gunzip < /prisma/backups/jwpwsofia-20240430-bak.gz | mysql -u mysql_username -p database_name
#export

View File

@ -1,19 +1,41 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import common from '../src/helpers/common'; // Ensure this path is correct import common from '../src/helpers/common'; // Ensure this path is correct
//use session to get user role
import { useSession } from "next-auth/react"
import e from 'express';
import ProtectedRoute from './protectedRoute';
import { UserRole } from '@prisma/client';
function PwaManager() { function PwaManager() {
//ToDo: for iOS, try to use apn? https://github.com/node-apn/node-apn/blob/master/doc/apn.markdown
const isSupported = () =>
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
const [inProgress, setInProgress] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState(null); const [deferredPrompt, setDeferredPrompt] = useState(null);
const [isPWAInstalled, setIsPWAInstalled] = useState(false); const [isPWAInstalled, setIsPWAInstalled] = useState(false);
const [isStandAlone, setIsStandAlone] = useState(false); const [isStandAlone, setIsStandAlone] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false);
const [subscription, setSubscription] = useState(null); const [subscription, setSubscription] = useState(null);
const [registration, setRegistration] = useState(null); const [registration, setRegistration] = useState(null);
const [notificationPermission, setNotificationPermission] = useState(Notification.permission); const [notificationPermission, setNotificationPermission] = useState(isSupported() && Notification.permission);
const [subs, setSubs] = useState("")
const { data: session } = useSession();
// let isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
let isAdmin = false;
if (session) {
isAdmin = session.user.role === UserRole.ADMIN;
}
// Handle PWA installation // Handle PWA installation
useEffect(() => { useEffect(() => {
if (isSupported()) {
setNotificationPermission(Notification.permission); setNotificationPermission(Notification.permission);
}
// Handle Push Notification Subscription // Handle Push Notification Subscription
if ('serviceWorker' in navigator && 'PushManager' in window) { if ('serviceWorker' in navigator && 'PushManager' in window) {
@ -28,9 +50,13 @@ function PwaManager() {
}); });
} }
// Check if the app is running in standalone mode // Check if the app is running in standalone mode
// const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
// if (isStandalone) {
// console.log('Running in standalone mode');
// setIsPWAInstalled(true);
// }
if (window.matchMedia('(display-mode: standalone)').matches) { if (window.matchMedia('(display-mode: standalone)').matches) {
setIsStandAlone(true); setIsStandAlone(true);
} }
@ -53,48 +79,80 @@ function PwaManager() {
}; };
}, []); }, []);
const installPWA = async (e) => {
e.preventDefault(); const installPWA = async (e) => {
console.log('Attempting to install PWA');
e.preventDefault(); // Prevent default button action
if (deferredPrompt) { if (deferredPrompt) {
console.log('Prompting install');
deferredPrompt.prompt(); deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice; const { outcome } = await deferredPrompt.userChoice;
console.log('Installation outcome:', outcome);
if (outcome === 'accepted') { if (outcome === 'accepted') {
console.log('User accepted the A2HS prompt');
setIsPWAInstalled(true); setIsPWAInstalled(true);
} else {
console.log('User dismissed the A2HS prompt');
} }
setDeferredPrompt(null); setDeferredPrompt(null); // Clear the deferred prompt to manage its lifecycle
} else {
console.log('No deferred prompt available');
} }
}; };
// Utility function for converting base64 string to Uint8Array
const base64ToUint8Array = base64 => {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(b64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
const subscribeToNotifications = async (e) => { const subscribeToNotifications = async (e) => {
try { try {
e.preventDefault(); e.preventDefault();
if (!navigator.serviceWorker) {
console.error('Service worker is not supported by this browser.');
return;
}
const registration = await navigator.serviceWorker.ready;
if (!registration) { if (!registration) {
console.error('Service worker registration not found.'); console.error('Service worker registration not found.');
registration
return; return;
} }
let vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
// Fetch the public key from the server if not present in env variables
const response = await fetch('/api/notify', { method: 'GET' });
const responseData = await response.json();
vapidPublicKey = responseData.pk;
setSubs(responseData.subs);
if (!vapidPublicKey) {
throw new Error("Failed to fetch VAPID public key from server.");
}
}
const sub = await registration.pushManager.subscribe({ const sub = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: base64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) applicationServerKey: common.base64ToUint8Array(vapidPublicKey)
}); });
// Call your API to save subscription data on server // Call your API to save subscription data on server
setSubscription(sub); if (session.user?.id != null) {
setIsSubscribed(true); await fetch(`/api/notify`, {
console.log('Web push subscribed!'); method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription: sub, id: session.user.id })
}).then(async response => {
if (!response.ok) {
throw new Error('Failed to save subscription data on server.');
}
else {
console.log('Subscription data saved on server.');
const s = await response.json();
setSubs(s.subs);
setSubscription(sub);
setIsSubscribed(true);
console.log('Web push subscribed!');
}
});
}
console.log(sub); console.log(sub);
} catch (error) { } catch (error) {
console.error('Error subscribing to notifications:', error); console.error('Error subscribing to notifications:', error);
@ -105,11 +163,34 @@ function PwaManager() {
try { try {
e.preventDefault(); e.preventDefault();
await subscription.unsubscribe(); if (subscription) {
// Call your API to delete or invalidate subscription data on server await subscription.unsubscribe();
setSubscription(null); // Call your API to delete or invalidate subscription data on server
setIsSubscribed(false); setSubscription(null);
console.log('Web push unsubscribed!'); setIsSubscribed(false);
if (session?.user?.id != null) {
await fetch(`/api/notify`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
//send the current subscription to be removed
body: JSON.stringify({ id: session.user.id, subscriptionId: subscription.endpoint })
}
).then(async (response) => {
if (!response.ok) {
throw new Error('Failed to delete subscription data on server.');
}
else {
console.log('Subscription data deleted on server.');
const s = await response.json();
setSubs(s.subs);
}
});
}
console.log('Web push unsubscribed!');
}
} catch (error) { } catch (error) {
console.error('Error unsubscribing from notifications:', error); console.error('Error unsubscribing from notifications:', error);
} }
@ -118,14 +199,19 @@ function PwaManager() {
// Function to request push notification permission // Function to request push notification permission
const requestNotificationPermission = async (e) => { const requestNotificationPermission = async (e) => {
e.preventDefault(); e.preventDefault();
const permission = await Notification.requestPermission(); if (isSupported()) {
setNotificationPermission(permission); const permission = await Notification.requestPermission();
if (permission === "granted") { setNotificationPermission(permission);
// User granted permission if (permission === "granted") {
subscribeToNotifications(null); // Pass the required argument here // User granted permission
} else { subscribeToNotifications(null); // Pass the required argument here
// User denied or dismissed permission } else {
console.log("Push notifications permission denied."); // User denied or dismissed permission
console.log("Push notifications permission denied.");
}
}
else {
console.error('Web push not supported');
} }
}; };
@ -160,49 +246,132 @@ function PwaManager() {
}); });
}; };
return ( async function sendTestReminder(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
<> event.preventDefault();
if (!subscription) {
console.error('Web push not subscribed');
return;
}
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ broadcast: true, message: 'Моля, въведете вашите предпочитания за юни до 25-ти май.' })
});
}
async function sendTestCoverMe(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
event.preventDefault();
if (!subscription) {
console.error('Web push not subscribed');
return;
}
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
broadcast: true, message: "Брат ТЕСТ търси заместник за 24-ти май от 10:00 ч. Можеш ли да го покриеш?",
//use fontawesome icons for actions
actions: [{ action: 'covermeaccepted', title: 'Да ', icon: '✅' }]
})
});
}
async function deleteAllSubscriptions(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
event.preventDefault();
await fetch(`/api/notify`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
//send the current subscription to be removed
body: JSON.stringify({ id: session.user.id })
}
).then(async response => {
if (!response.ok) {
throw new Error('Failed to delete subscription data on server.');
}
else {
console.log('ALL subscriptions data deleted on server.');
if (subscription) {
await subscription.unsubscribe();
}
setSubs("");
setSubscription(null);
setIsSubscribed(false);
}
});
}
if (!isSupported()) {
return (
<div> <div>
<h1>PWA Manager</h1> <p>Това устройство не поддържа нотификации</p>
{!isStandAlone && !isPWAInstalled && (
<button
onClick={installPWA}
className="bg-blue-500 hover:bg-blue-700 text-white text-xs py-1 px-2 rounded-full focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
>
Install PWA
</button>
)}
{isPWAInstalled && <p>App is installed!</p>}
{isStandAlone && <p>PWA App</p>}
<button
onClick={subscribeToNotifications}
disabled={isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-green-500 hover:bg-green-700 text-white'
}`}
>
Subscribe to Notifications
</button>
<button
onClick={unsubscribeFromNotifications}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-red-500 hover:bg-red-700 text-white'
}`}
>
Unsubscribe from Notifications
</button>
</div> </div>
<div> );
<button }
onClick={sendTestNotification} else {
disabled={!isSubscribed} return (
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white' <>
}`} <div>
> <h1>{isAdmin && " PWA (admin)"}</h1>
Send Test Notification {!isStandAlone && !isPWAInstalled && (
</button> <button
onClick={installPWA}
className="bg-blue-500 hover:bg-blue-700 text-white text-xs py-1 px-2 rounded-full focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"
>
Инсталирай приложението
</button>
)}
{isPWAInstalled && <p>Инсталирано!</p>}
{/* {isStandAlone && <p>PWA App</p>} */}
<button
onClick={isSubscribed ? unsubscribeFromNotifications : subscribeToNotifications}
disabled={false} // Since the button itself acts as a toggle, the disabled attribute might not be needed
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${isSubscribed ? 'bg-red-500 hover:bg-red-700 text-white' : 'bg-green-500 hover:bg-green-700 text-white'}`} >
{isSubscribed ? 'Спри известията' : 'Показвай известия'}
</button>
<button
onClick={deleteAllSubscriptions}
className="text-xs py-1 px-2 rounded-full focus:outline-none bg-red-500 hover:bg-red-700 text-white"
>
Спри известията на всички мои устройства {subs != "" ? `(${subs})` : ""}
</button>
</div>
{isAdmin &&
<div>
<button
onClick={sendTestNotification}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Send Test Notification
</button>
<button
onClick={sendTestReminder}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Broadcast Reminder
</button>
<button
onClick={sendTestCoverMe}
disabled={!isSubscribed}
className={`text-xs py-1 px-2 rounded-full focus:outline-none transition duration-150 ease-in-out ${!isSubscribed ? 'cursor-not-allowed bg-gray-300 text-gray-500' : 'bg-yellow-500 hover:bg-yellow-600 text-white'
}`}
>
Broadcast CoverMe
</button>
</div>
}
{notificationPermission !== "granted" && ( {notificationPermission !== "granted" && (
<button <button
onClick={togglePushNotifications} onClick={togglePushNotifications}
@ -212,21 +381,22 @@ function PwaManager() {
{notificationPermission === "denied" ? 'Notifications Denied!' : 'Enable Notifications'} {notificationPermission === "denied" ? 'Notifications Denied!' : 'Enable Notifications'}
</button> </button>
)} )}
</div>
<div>
<a href="https://t.me/mwhitnessing_bot" className="inline-flex items-center ml-4" target="_blank">
<img src="/content/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" />
<span className="align-middle">Телеграм</span>
</a>
</div>
<div>
<a href="/api/auth/apple-signin" className="inline-flex items-center ml-4" target="_blank">
<span className="align-middle">Apple sign-in</span>
</a>
</div>
</>
);
{isAdmin && <div>
<div>
<a href="https://t.me/mwhitnessing_bot" className="inline-flex items-center ml-4" target="_blank">
<img src="/content/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" />
<span className="align-middle">Телеграм</span>
</a>
<a href="/api/auth/apple-signin" className="inline-flex items-center ml-4 bg-gray-100 button" target="_blank">
<span className="align-middle">Apple sign-in</span>
</a>
</div>
</div>
}
</>
);
}
} }
export default PwaManager; export default PwaManager;

View File

@ -10,7 +10,10 @@ import { bgBG } from '../x-date-pickers/locales/bgBG';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
const common = require('src/helpers/common'); const common = require('src/helpers/common');
//todo import Availability type from prisma schema //todo import Availability type from prisma schema
import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns'; import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns'; //ToDo obsolete
import { stat } from 'fs';
const { DateTime, FixedOffsetZone } = require('luxon');
@ -19,7 +22,7 @@ const fetchConfig = async () => {
return config.default; return config.default;
}; };
export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, datePicker = false }) { export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, cartEvent, datePicker = false }) {
const router = useRouter(); const router = useRouter();
const urls = { const urls = {
@ -65,14 +68,16 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
}, []); }, []);
// Define the minimum and maximum times // get cart event or set default time for Sofia timezone
const minTime = new Date(); // const minTime = cartEvent?.startTime || DateTime.now().set({ hour: 8, minute: 0, zone: 'Europe/Sofia' }).toJSDate();
minTime.setHours(9, 0, 0, 0); // 8:00 AM // const maxTime = cartEvent?.endTime || DateTime.now().set({ hour: 20, minute: 0, zone: 'Europe/Sofia' }).toJSDate();
const maxTime = new Date(); const d = DateTime.fromJSDate(day).setZone('Europe/Sofia', { keepLocalTime: true });
maxTime.setHours(19, 30, 0, 0); // 8:00 PM const minTime = d.set({ hour: 9, minute: 0 }).toJSDate();
const maxTime = d.set({ hour: 19, minute: 30 }).toJSDate();
useEffect(() => { useEffect(() => {
setTimeSlots(generateTimeSlots(minTime, maxTime, 90, availabilities)); setTimeSlots(generateTimeSlots(new Date(minTime), new Date(maxTime), cartEvent?.shiftDuration || 90, availabilities));
console.log("AvailabilityForm: minTime: " + common.getTimeFormatted(minTime) + ", maxTime: " + common.getTimeFormatted(maxTime), ", " + cartEvent?.shiftDuration || 90 + " min. shifts", cartEvent ? "cartEvent" : "cartEvent MISSING!!!");
}, []); }, []);
@ -187,15 +192,14 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
// Common function to set shared properties // Common function to set shared properties
function setSharedAvailabilityProperties(availability, group, timeSlots) { function setSharedAvailabilityProperties(availability, group, timeSlots) {
let startTime = new Date(availability.startTime || day); const d = DateTime.fromJSDate(day).setZone('Europe/Sofia', { keepLocalTime: true });
startTime.setHours(group[0].startTime.getHours(), group[0].startTime.getMinutes(), group[0].startTime.getSeconds(), 0); console.log("day: " + d.toISODate());
let startTime = common.setTime(d, group[0].startTime).toJSDate();
let endTime = new Date(availability.endTime || day); let endTime = common.setTime(d, group[group.length - 1].endTime).toJSDate();
endTime.setHours(group[group.length - 1].endTime.getHours(), group[group.length - 1].endTime.getMinutes(), group[group.length - 1].endTime.getSeconds(), 0);
availability.startTime = startTime; availability.startTime = startTime;
availability.endTime = endTime; availability.endTime = endTime;
availability.name = common.getTimeFomatted(startTime) + "-" + common.getTimeFomatted(endTime); availability.name = common.getTimeFormatted(group[0].startTime) + "-" + common.getTimeFormatted(group[group.length - 1].endTime);
availability.isWithTransportIn = group[0].isFirst && timeSlots[0].isWithTransport; availability.isWithTransportIn = group[0].isFirst && timeSlots[0].isWithTransport;
availability.isWithTransportOut = group[group.length - 1].isLast && timeSlots[timeSlots.length - 1].isWithTransport; availability.isWithTransportOut = group[group.length - 1].isLast && timeSlots[timeSlots.length - 1].isWithTransport;
@ -209,7 +213,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
} else { } else {
availability.type = "OneTime" availability.type = "OneTime"
availability.repeatWeekly = false; availability.repeatWeekly = false;
availability.dayOfMonth = startTime.getDate(); availability.dayOfMonth = availability.startTime.getDate();
availability.endDate = null; availability.endDate = null;
} }
availability.isFromPreviousMonth = false; availability.isFromPreviousMonth = false;
@ -285,28 +289,17 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
const generateTimeSlots = (start, end, increment, items) => { const generateTimeSlots = (start, end, increment, items) => {
const slots = []; const slots = [];
let currentTime = start; let currentTime = start;
const baseDate = new Date(Date.UTC(2000, 0, 1, 0, 0, 0));
while (isBefore(currentTime, end)) { while (isBefore(currentTime, end)) {
let slotStart = normalizeTime(currentTime, baseDate); let slotStart = currentTime;
let slotEnd = normalizeTime(addMinutes(currentTime, increment), baseDate); let slotEnd = addMinutes(currentTime, increment);
const isChecked = items.some(item => { const isChecked = items.some(item => {
let itemStart = item.startTime ? normalizeTime(new Date(item.startTime), baseDate) : null; return item.startTime && item.endTime &&
let itemEnd = item.endTime ? normalizeTime(new Date(item.endTime), baseDate) : null; common.isTimeBetween(item.startTime, item.endTime, slotStart) &&
common.isTimeBetween(item.startTime, item.endTime, slotEnd);
return itemStart && itemEnd &&
(slotStart.getTime() < itemEnd.getTime()) &&
(slotEnd.getTime() > itemStart.getTime());
});
slots.push({
startTime: slotStart,
endTime: slotEnd,
isChecked: isChecked,
}); });
slots.push({ startTime: slotStart, endTime: slotEnd, isChecked: isChecked, });
currentTime = addMinutes(currentTime, increment); currentTime = addMinutes(currentTime, increment);
} }
@ -320,16 +313,6 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
return slots; return slots;
}; };
// Normalize the time part of a date by using a base date
function normalizeTime(date, baseDate) {
return set(baseDate, {
hours: getHours(date),
minutes: getMinutes(date),
seconds: getSeconds(date),
milliseconds: 0
});
}
const TimeSlotCheckboxes = ({ slots, setSlots, items: [] }) => { const TimeSlotCheckboxes = ({ slots, setSlots, items: [] }) => {
const [allDay, setAllDay] = useState(slots.every(slot => slot.isChecked)); const [allDay, setAllDay] = useState(slots.every(slot => slot.isChecked));
const handleAllDayChange = (e) => { const handleAllDayChange = (e) => {
@ -390,7 +373,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
<span className="checkmark"></span> <span className="checkmark"></span>
</label> </label>
{slots.map((slot, index) => { {slots.map((slot, index) => {
const slotLabel = `${common.getTimeFomatted(slot.startTime)} до ${common.getTimeFomatted(slot.endTime)}`; const slotLabel = `${common.getTimeFormatted(slot.startTime)} до ${common.getTimeFormatted(slot.endTime)}`;
slot.transportNeeded = slot.isFirst || slot.isLast; slot.transportNeeded = slot.isFirst || slot.isLast;
// Determine if the current slot is the first or the last // Determine if the current slot is the first or the last

View File

@ -167,7 +167,7 @@ export default function AvailabilityForm({ publisherId, existingItem, inline, on
if (!availability.name) { if (!availability.name) {
// availability.name = "От календара"; // availability.name = "От календара";
availability.name = common.getTimeFomatted(availability.startTime) + "-" + common.getTimeFomatted(availability.endTime); availability.name = common.getTimeFormatted(availability.startTime) + "-" + common.getTimeFormatted(availability.endTime);
} }
availability.dayofweek = common.getDayOfWeekNameEnEnumForDate(availability.startTime); availability.dayofweek = common.getDayOfWeekNameEnEnumForDate(availability.startTime);

View File

@ -173,6 +173,9 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
borderStyles += 'border-l-2 border-blue-500 '; // Left border for specific availability conditions borderStyles += 'border-l-2 border-blue-500 '; // Left border for specific availability conditions
ass.canTransport = av.isWithTransportIn || av.isWithTransportOut; ass.canTransport = av.isWithTransportIn || av.isWithTransportOut;
} }
else {
borderStyles += 'border-l-4 border-red-500 ';
}
if (publisherInfo.hasUpToDateAvailabilities) { if (publisherInfo.hasUpToDateAvailabilities) {
//add green right border //add green right border

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, use } from 'react';
import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar'; import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar';
import 'react-big-calendar/lib/css/react-big-calendar.css'; import 'react-big-calendar/lib/css/react-big-calendar.css';
import AvailabilityForm from '../availability/AvailabilityForm'; import AvailabilityForm from '../availability/AvailabilityForm';
@ -9,7 +9,7 @@ import common from '../../src/helpers/common';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import moment from 'moment'; import moment from 'moment'; // ToDo: obsolete, remove it
import 'moment/locale/bg'; // Import Bulgarian locale import 'moment/locale/bg'; // Import Bulgarian locale
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline'; import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
@ -18,11 +18,13 @@ import { MdToday } from 'react-icons/md';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import axiosInstance from '../../src/axiosSecure'; import axiosInstance from '../../src/axiosSecure';
import { set } from 'date-fns';
import { get } from 'http';
// import { set, format, addDays } from 'date-fns'; // import { set, format, addDays } from 'date-fns';
// import { isEqual, isSameDay, getHours, getMinutes } from 'date-fns'; // import { isEqual, isSameDay, getHours, getMinutes } from 'date-fns';
import { filter } from 'jszip'; // import { filter } from 'jszip';
import e from 'express'; // import e from 'express';
@ -46,7 +48,7 @@ const messages = {
// Any other labels you want to translate... // Any other labels you want to translate...
}; };
const AvCalendar = ({ publisherId, events, selectedDate }) => { const AvCalendar = ({ publisherId, events, selectedDate, cartEvents }) => {
const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN); const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN);
@ -65,7 +67,18 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
return { start, end }; return { start, end };
}); });
const [cartEvent, setCartEvent] = useState(null);
function getCartEvent(date) {
const dayOfWeek = common.getDayOfWeekNameEnEnumForDate(date);
const ce = cartEvents?.find(e => e.dayofweek === dayOfWeek);
return ce;
}
useEffect(() => {
//console.log("useEffect: ", date, selectedEvents, cartEvents);
setCartEvent(getCartEvent(date));
},
[date, selectedEvents]);
// Update internal state when `events` prop changes // Update internal state when `events` prop changes
useEffect(() => { useEffect(() => {
@ -113,6 +126,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
//setDisplayedEvents(evts); //setDisplayedEvents(evts);
}, [visibleRange, evts, currentView]); }, [visibleRange, evts, currentView]);
// todo: review that
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: () => navigate('NEXT'), onSwipedLeft: () => navigate('NEXT'),
onSwipedRight: () => navigate('PREV'), onSwipedRight: () => navigate('PREV'),
@ -201,18 +215,13 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
return existingEvents; return existingEvents;
}; };
// Define min and max times
const minHour = 8; // 8:00 AM // const totalHours = maxHour - minHour;
const maxHour = 20; // 8:00 PM
const minTime = new Date();
minTime.setHours(minHour, 0, 0);
const maxTime = new Date();
maxTime.setHours(maxHour, 0, 0);
const totalHours = maxHour - minHour;
const handleSelect = ({ mode, start, end }) => { const handleSelect = ({ mode, start, end }) => {
const startdate = typeof start === 'string' ? new Date(start) : start; //we set the time to proper timezone
const enddate = typeof end === 'string' ? new Date(end) : end; const startdate = common.setTimezone(start);
const enddate = common.setTimezone(end);
if (!start || !end) return; if (!start || !end) return;
//readonly for past dates (ToDo: if not admin) //readonly for past dates (ToDo: if not admin)
@ -224,27 +233,15 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
end = common.setTimeHHmm(startdate, "23:59"); end = common.setTimeHHmm(startdate, "23:59");
} }
const startMinutes = common.getTimeInMinutes(start); // Update date state and calculate events based on the new startdate
const endMinutes = common.getTimeInMinutes(end); setDate(startdate);
const existingEvents = filterEvents(evts, publisherId, startdate);
console.log("handleSelect: ", existingEvents);
// Adjust start and end times to be within min and max hours // Use the updated startdate for getCartEvent and ensure it reflects in the state properly
if (startMinutes < common.getTimeInMinutes(common.setTimeHHmm(start, minHour))) { const cartEvent = getCartEvent(startdate);
start = common.setTimeHHmm(start, minHour); setCartEvent(cartEvent);
} console.log("cartEvent: ", cartEvent);
if (endMinutes > common.getTimeInMinutes(common.setTimeHHmm(end, maxHour))) {
end = common.setTimeHHmm(end, maxHour);
}
setDate(start);
// get exising events for the selected date
//ToDo: properly fix this. filterEvents does not return the expcted results
let existingEvents = filterEvents(evts, publisherId, startdate);
// if existingEvents is empty - create new with the selected range
// if (existingEvents.length === 0) {
// existingEvents = [{ startTime: start, endTime: end }];
// }
console.log("handleSelect: " + existingEvents);
setSelectedEvents(existingEvents); setSelectedEvents(existingEvents);
setIsModalOpen(true); setIsModalOpen(true);
}; };
@ -353,15 +350,15 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
bgColor = event.isBySystem ? "bg-red-500" : (event.isConfirmed || true ? "bg-green-500" : "bg-yellow-500"); bgColor = event.isBySystem ? "bg-red-500" : (event.isConfirmed || true ? "bg-green-500" : "bg-yellow-500");
//event.title = event.publisher.name; //ToDo: add other publishers names //event.title = event.publisher.name; //ToDo: add other publishers names
//event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime); //event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
} else { } else {
if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) { if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) {
try { try {
if (event.type === "recurring") { if (event.type === "recurring") {
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime); event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
} }
else { else {
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime); event.title = common.getTimeFormatted(event.startTime) + " - " + common.getTimeFormatted(event.endTime);
} }
} }
catch (err) { catch (err) {
@ -509,8 +506,8 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
onSelectSlot={handleSelect} onSelectSlot={handleSelect}
onSelectEvent={handleEventClick} onSelectEvent={handleEventClick}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
min={minTime} // Set minimum time min={cartEvent?.startTime} // Set minimum time
max={maxTime} // Set maximum time max={cartEvent?.endTime} // Set maximum time
messages={messages} messages={messages}
view={currentView} view={currentView}
views={['month', 'week', 'agenda']} views={['month', 'week', 'agenda']}
@ -530,6 +527,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
showAllEvents={true} showAllEvents={true}
onNavigate={setDate} onNavigate={setDate}
className="rounded-lg shadow-lg" className="rounded-lg shadow-lg"
longPressThreshold={150} // default value 250
/> />
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50"> <div className="fixed inset-0 flex items-center justify-center z-50">
@ -540,6 +538,7 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
date={date} date={date}
onDone={handleDialogClose} onDone={handleDialogClose}
inline={true} inline={true}
cartEvent={cartEvent}
// Pass other props as needed // Pass other props as needed
/> />
</div> </div>

View File

@ -69,8 +69,8 @@ export default function CartEventForm(props: IProps) {
try { try {
console.log("fetching cart event from component " + router.query.id); console.log("fetching cart event from component " + router.query.id);
const { data } = await axiosInstance.get(urls.apiUrl + id); const { data } = await axiosInstance.get(urls.apiUrl + id);
data.startTime = common.formatTimeHHmm(data.startTime) data.startTime = common.getTimeFormatted(data.startTime)
data.endTime = common.formatTimeHHmm(data.endTime) data.endTime = common.getTimeFormatted(data.endTime)
setEvt(data); setEvt(data);
console.log("id:" + evt.id); console.log("id:" + evt.id);

View File

@ -246,15 +246,65 @@ export default function PublisherForm({ item, me }) {
<label className="label" htmlFor="town">Град</label> <label className="label" htmlFor="town">Град</label>
<input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus /> <input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
</div> </div>
<div className="mb-4">
{/* notifications */} {/* notifications */}
<div className="form-check"> <div className="mb-6 p-4 border border-gray-300 rounded-lg">
<input className="checkbox" type="checkbox" id="isSubscribedToCoverMe" name="isSubscribedToCoverMe" onChange={handleChange} checked={publisher.isSubscribedToCoverMe} autoComplete="off" /> <fieldset>
<label className="label" htmlFor="isSubscribedToCoverMe">Абониран за имейли за заместване</label> <legend className="text-lg font-medium mb-2">Известия</legend>
<input className="checkbox" type="checkbox" id="isSubscribedToReminders" name="isSubscribedToReminders" onChange={handleChange} checked={publisher.isSubscribedToReminders} autoComplete="off" />
<label className="label" htmlFor="isSubscribedToReminders">Абониран за напомняния (имейл)</label> {/* Email notifications group */}
{/* prompt to install PWA */} <div className="mb-4">
</div> <h3 className="text-md font-semibold mb-2">Известия по имейл</h3>
<div className="space-y-4">
<div className="form-check">
<input
className="checkbox cursor-not-allowed opacity-50"
type="checkbox"
id="isSubscribedToCoverMeMandatory"
name="isSubscribedToCoverMeMandatory"
onChange={handleChange} // This will not fire due to being disabled, but kept for consistency
checked={true} // Always checked
disabled={true} // User cannot change this field
autoComplete="off" />
<label className="label cursor-not-allowed opacity-50" htmlFor="isSubscribedToCoverMeMandatory">
Имейли за заместване които отговарят на моите предпочитания
</label>
</div>
<div className="form-check">
<input
className="checkbox"
type="checkbox"
id="isSubscribedToCoverMe"
name="isSubscribedToCoverMe"
onChange={handleChange}
checked={publisher.isSubscribedToCoverMe}
autoComplete="off" />
<label className="label" htmlFor="isSubscribedToCoverMe">
Всички заявки за заместване
</label>
</div>
<div className="form-check">
<input
className="checkbox"
type="checkbox"
id="isSubscribedToReminders"
name="isSubscribedToReminders"
onChange={handleChange}
checked={publisher.isSubscribedToReminders}
autoComplete="off" />
<label className="label" htmlFor="isSubscribedToReminders">
Други напомняния
</label>
</div>
</div>
</div>
{/* In-App notifications group */}
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Известия в приложението</h3>
<PwaManager />
</div>
</fieldset>
</div> </div>
{/* button to install PWA */} {/* button to install PWA */}
@ -267,7 +317,7 @@ export default function PublisherForm({ item, me }) {
{/* ADMINISTRATORS ONLY */} {/* ADMINISTRATORS ONLY */}
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" " className=""> <ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" " className="">
<div className="border border-blue-500 border-solid p-2"> <div className="border border-blue-500 border-solid p-2">
<PwaManager /> {/* prompt to install PWA */}
<div className="mb-4"> <div className="mb-4">
<label className="label" htmlFor="type">Тип</label> <label className="label" htmlFor="type">Тип</label>
<select id="type" name="type" value={publisher.type} onChange={handleChange} className="textbox" placeholder="Type" autoFocus > <select id="type" name="type" value={publisher.type} onChange={handleChange} className="textbox" placeholder="Type" autoFocus >
@ -326,7 +376,7 @@ export default function PublisherForm({ item, me }) {
{/* save */} {/* save */}
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit"> <button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
{router.query?.id ? "Update" : "Create"} {router.query?.id ? "Запази" : "Създай"}
</button> </button>
</div> </div>
</form> </form>

View File

@ -11,14 +11,20 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]);
const [selectedDate, setSelectedDate] = useState(filterDate); const [selectedDate, setSelectedDate] = useState(filterDate);
// useEffect(() => {
// fetchPublishers();
// }, []); // Empty dependency array ensures this useEffect runs only once
// Update publishers when filterDate or showList changes
useEffect(() => { useEffect(() => {
fetchPublishers(); fetchPublishers();
}, []); // Empty dependency array ensures this useEffect runs only once }, [filterDate, showList]);
const fetchPublishers = async () => { const fetchPublishers = async () => {
console.log("fetchPublishers called"); console.log("fetchPublishers called");
try { try {
let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isActive&searchText=${searchText}&availabilities=false`; let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isActive&availabilities=false`;
if (filterDate) { if (filterDate) {
url += `&filterDate=${common.getISODateOnly(filterDate)}`; url += `&filterDate=${common.getISODateOnly(filterDate)}`;
@ -60,10 +66,7 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
// console.log("filterDate changed = ", filterDate); // console.log("filterDate changed = ", filterDate);
// }, [filterDate]); // }, [filterDate]);
// Update publishers when filterDate or showList changes
useEffect(() => {
fetchPublishers();
}, [filterDate, showList]);
// Update selectedItem when selectedId changes and also at the initial load // Update selectedItem when selectedId changes and also at the initial load
useEffect(() => { useEffect(() => {

View File

@ -85,43 +85,46 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, subscribedPublishers, a
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50"> <div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black opacity-50" onClick={onClose}></div> <div className="absolute inset-0 bg-black opacity-50" onClick={onClose}></div>
<div className="bg-white p-6 rounded-lg shadow-lg z-10"> <div className="bg-white p-6 rounded-lg shadow-lg z-10 max-h-screen overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Можете да изпратите заявка за заместник до следните групи:</h2> <h2 className="text-lg font-semibold mb-4">Можете да изпратите заявка за заместник до следните групи:</h2>
<div className="mb-4"> <div className="space-y-1">
<label className="block mb-2"> <div className="flex items-center mb-2">
<div className="flex items-center mb-2"> <input id="subscribedPublishersCheckbox"
<input type="checkbox"
type="checkbox" className="mr-2 leading-tight"
className="mr-2 leading-tight" checked={selectedGroups.includes('subscribedPublishers')}
checked={selectedGroups.includes('subscribedPublishers')} onChange={() => handleToggleGroup('subscribedPublishers')}
onChange={() => handleToggleGroup('subscribedPublishers')} />
/> <label htmlFor="subscribedPublishersCheckbox" className="text-sm font-medium">Абонирани:</label>
<span className="text-sm font-medium">Абонирани:</span> </div>
</div> <div className="overflow-y-auto max-h-64">
<div className="flex flex-wrap"> <label className="block mb-2">
{subscribedPublishers.map(pub => ( <div className="flex flex-wrap">
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span> {subscribedPublishers.map(pub => (
))} <span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
</div> ))}
</label> </div>
</div> </label>
<div className="mb-4"> </div>
<label className="block mb-2"> <div className="flex items-center mb-2">
<div className="flex items-center mb-2"> <input id="availablePublishersCheckbox"
<input type="checkbox"
type="checkbox" className="mr-2 leading-tight"
className="mr-2 leading-tight" checked={selectedGroups.includes('availablePublishers')}
checked={selectedGroups.includes('availablePublishers')} onChange={() => handleToggleGroup('availablePublishers')}
onChange={() => handleToggleGroup('availablePublishers')} />
/> <label htmlFor="availablePublishersCheckbox" className="text-sm font-medium">На разположение :</label>
<span className="text-sm font-medium">На разположение :</span> </div>
</div> <div className="overflow-y-auto max-h-64">
<div className="flex flex-wrap"> <label className="block mb-2">
{availablePublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span> <div className="flex flex-wrap">
))} {availablePublishers.map(pub => (
</div> <span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
</label> ))}
</div>
</label>
</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<button <button

View File

@ -142,7 +142,7 @@ export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
return ( return (
<> <>
<button onClick={toggleSidebar} <button onClick={toggleSidebar}
className="fixed top-1 left-4 z-40 m- text-xl bg-white border border-gray-200 p-2 rounded-full shadow-lg focus:outline-none" className="fixed top-1 left-5 z-40 m- text-xl bg-white border border-gray-200 px-3 py-2.5 rounded-full shadow-lg focus:outline-none"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}></button> style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}></button>
<aside id="sidenav" ref={sidebarRef} <aside id="sidenav" ref={sidebarRef}
className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 sm:translate-x-0 w-64" className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 sm:translate-x-0 w-64"
@ -196,11 +196,11 @@ function UserDetails({ session }) {
return ( return (
<> <>
<hr className="m-0" /> <hr className="m-0" />
<div className="flex items-center"> <div className="items-center">
{session.user.image && ( {session.user.image && (
<img className="object-cover mx-2 rounded-full h-9 w-9" src={session.user.image} alt="avatar" /> <img className="object-cover mx-2 rounded-full h-9 w-9" src={session.user.image} alt="avatar" />
)} )}
<div className="ml-3 overflow-hidden"> <div className="overflow-hidden">
<p className="mx-1 mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{session.user.name}</p> <p className="mx-1 mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{session.user.name}</p>
<p className="mx-1 mt-1 text-sm font-medium text-gray-600 dark:text-gray-400">{session.user.role}</p> <p className="mx-1 mt-1 text-sm font-medium text-gray-600 dark:text-gray-400">{session.user.role}</p>
<a href="/api/auth/signout" className={styles.button} onClick={(e) => { e.preventDefault(); signOut(); }}> <a href="/api/auth/signout" className={styles.button} onClick={(e) => { e.preventDefault(); signOut(); }}>

View File

@ -1,11 +1,13 @@
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//we're currently using next-pwa (which uses GeneraeSW automatically), so we don't need to use workbox-webpack-plugin
// const { InjectManifest, GenerateSW } = require('workbox-webpack-plugin');
const withPWA = require('next-pwa')({ const withPWA = require('next-pwa')({
dest: 'public', dest: 'public',
register: true, // ? register: true, // ?
publicExcludes: ["!_error*.js"], //? publicExcludes: ["!_error*.js"], //?
skipWaiting: true,
//disable: process.env.NODE_ENV === 'development', // disable: process.env.NODE_ENV === 'development',
}) })
module.exports = withPWA({ module.exports = withPWA({
@ -16,29 +18,76 @@ module.exports = withPWA({
// !! WARN !! // !! WARN !!
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
compress: false, compress: true,
pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?` pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?`
env: { env: {
env: process.env.NODE_ENV, env: process.env.APP_ENV,
server: process.env.NEXT_PUBLIC_PUBLIC_URL server: process.env.NEXT_PUBLIC_PUBLIC_URL
}, },
webpack(config, { isServer }) { // pwa: {
// dest: 'public',
// register: true,
// publicExcludes: ["!_error*.js"],
// disable: process.env.NODE_ENV === 'development',
// // sw: './worker/index.js', // Custom service worker file name
// },
config.optimization.minimize = true, // plugins: [
productionBrowserSourceMaps = true, // // new InjectManifest({
// // // These are some common options, and not all are required.
// // // Consult the docs for more info.
// // //exclude: [/.../, '...'],
// // maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// // }),
// // new GenerateSW({
// // //disable all files
// // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
// // // swSrc: './worker.js',
// // }),
// ],
webpack: (config, { isServer, buildId, dev }) => {
// Configure optimization and source maps
config.optimization.minimize = !dev;
//config.productionBrowserSourceMaps = true;
// Enable source maps based on non-production environments
if (!dev) {
//config.devtool = 'source-map';
/* TypeError: Invalid character in header content ["Location"]
at ServerResponse.setHeader (node:_http_outgoing:655:3)
at _res.setHeader (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:481:24)
at NodeNextResponse.setHeader (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-http\node.js:74:19)
at NodeNextResponse.redirect (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-http\index.js:43:14)
at handleRedirect (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1208:17)
at DevServer.renderToResponseWithComponentsImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1666:23)
at async DevServer.renderPageComponent (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1856:24)
at async DevServer.renderToResponseImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:1894:32)
at async DevServer.pipeImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:911:25)
at async NextNodeServer.handleCatchallRenderRequest (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\next-server.js:271:17)
at async DevServer.handleRequestImpl (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\base-server.js:807:17)
at async D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\dev\next-dev-server.js:331:20
at async Span.traceAsyncFn (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\trace\trace.js:151:20)
at async DevServer.handleRequest (D:\DEV\workspace\REPOS\git.d-popov.com\mwhitnessing\node_modules\next\dist\server\dev\next-dev-server.js:328:24) {
code: 'ERR_INVALID_CHAR',
page: '/cart/publishers/edit/react_devtools_backend_compact.js.map' */
}
// Add custom fallbacks
config.resolve.fallback = { ...config.resolve.fallback, fs: false };
config.resolve.fallback = { // InjectManifest configuration
if (!isServer) {
// config.plugins.push(new InjectManifest({
// // swSrc: './worker.js', // Path to source service worker file
// // swDest: '/worker.js', // Destination filename in the build output
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed
// exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns
// })
// );
}
// if you miss it, all the other options in fallback, specified // Bundle Analyzer Configuration
// by next.js will be dropped.
...config.resolve.fallback,
fs: false, // the solution
};
// Only run the bundle analyzer for production builds and when the ANALYZE environment variable is set
if (process.env.ANALYZE && !isServer) { if (process.env.ANALYZE && !isServer) {
//const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
config.plugins.push( config.plugins.push(
new BundleAnalyzerPlugin({ new BundleAnalyzerPlugin({
analyzerMode: 'static', analyzerMode: 'static',

926
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{ {
"name": "pwwa", "name": "smws",
"version": "1.2.2", "version": "1.2.4",
"private": true, "private": true,
"description": "JW PW Web App", "description": "SMWS | ССОМ | Специално Свидетелстване София",
"repository": "http://git.d-popov.com/popov/next-cart-app.git", "repository": "http://git.d-popov.com/popov/next-cart-app.git",
"bugs": { "bugs": {
"url": "https://git.d-popov.com/popov/next-cart-app/issues" "url": "https://git.d-popov.com/popov/next-cart-app/issues"
@ -11,7 +11,8 @@
"scripts": { "scripts": {
"debug": "node server.js", "debug": "node server.js",
"debug-env": "dotenv -e .env.$APP_ENV -- nodemon --inspect server.js", "debug-env": "dotenv -e .env.$APP_ENV -- nodemon --inspect server.js",
"nodeenv": "dotenv -e .env.$APP_ENV -- node server.js", "start-env": "dotenv -e .env.$APP_ENV -- node server.js",
"run-env": "dotenv -e .env.$APP_ENV -- npm run build && dotenv -e .env.$APP_ENV -- npm run start",
"prod": "dotenv -e .env.production -- node server.js", "prod": "dotenv -e .env.production -- node server.js",
"build": "next build", "build": "next build",
"buildWin": "npm run build", "buildWin": "npm run build",
@ -66,6 +67,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"levenshtein-edit-distance": "^3.0.1", "levenshtein-edit-distance": "^3.0.1",
"luxon": "^3.4.4",
"mailtrap": "^3.3.0", "mailtrap": "^3.3.0",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"moment": "^2.30.1", "moment": "^2.30.1",
@ -106,6 +108,7 @@
"webpack-bundle-analyzer": "^4.10.1", "webpack-bundle-analyzer": "^4.10.1",
"winston": "^3.13.0", "winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
"workbox-webpack-plugin": "^7.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.1/xlsx-0.19.1.tgz",
"xlsx-style": "^0.8.13", "xlsx-style": "^0.8.13",
"xml-js": "^1.6.11", "xml-js": "^1.6.11",

View File

@ -26,9 +26,47 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
// appleWebApp: true, // appleWebApp: true,
// } // }
// (custom) Service worker registration and push notification logic
// function registerServiceWorkerAndPushNotifications() {
// useEffect(() => {
// const registerServiceWorker = async () => {
// if ('serviceWorker' in navigator) {
// try {
// const registration = await navigator.serviceWorker.register('/worker/index.js')
// .then((registration) => console.log('reg: ', registration));
// } catch (error) {
// console.log('Service Worker registration failed:', error);
// }
// }
// };
// const askForNotificationPermission = async () => {
// if ('serviceWorker' in navigator && 'PushManager' in window) {
// try {
// const permission = await Notification.requestPermission();
// if (permission === 'granted') {
// console.log('Notification permission granted.');
// // TODO: Subscribe the user to push notifications here
// } else {
// console.log('Notification permission not granted.');
// }
// } catch (error) {
// console.error('Error during service worker registration:', error);
// }
// } else {
// console.log('Service Worker or Push notifications not supported in this browser.');
// }
// };
// registerServiceWorker();
// askForNotificationPermission();
// }, []);
// }
//function SmwsApp({ Component, pageProps: { locale, messages, session, ...pageProps }, }: AppProps<{ session: Session }>) { //function SmwsApp({ Component, pageProps: { locale, messages, session, ...pageProps }, }: AppProps<{ session: Session }>) {
function SmwsApp({ Component, pageProps, session, locale, messages }) { function SmwsApp({ Component, pageProps, session, locale, messages }) {
//registerServiceWorkerAndPushNotifications();
// dynamic locale loading using our API endpoint // dynamic locale loading using our API endpoint
// const [locale, setLocale] = useState(_locale); // const [locale, setLocale] = useState(_locale);
// const [messages, setMessages] = useState(_messages); // const [messages, setMessages] = useState(_messages);

View File

@ -14,8 +14,7 @@ class MyDocument extends Document {
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CCOM" /> <meta name="apple-mobile-web-app-title" content="CCOM" />
<link rel="apple-touch-icon" href="/favicon.ico"></link>
<link rel="apple-touch-icon" href="/old-192x192.png"></link>
</Head> </Head>
<body> <body>
<Main /> <Main />

View File

@ -86,9 +86,9 @@ export const authOptions: NextAuthOptions = {
// // Return null if user data could not be retrieved // // Return null if user data could not be retrieved
// return null // return null
const users = [ const users = [
{ id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN" }, { id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN", static: true },
{ id: "2", name: "krasi", email: "krasi@example.com", password: "krasi123", role: "ADMIN" }, { id: "2", name: "krasi", email: "krasi@example.com", password: "krasi123", role: "ADMIN", static: true },
{ id: "3", name: "popov", email: "popov@example.com", password: "popov123", role: "ADMIN" } { id: "3", name: "popov", email: "popov@example.com", password: "popov123", role: "ADMIN", static: true }
]; ];
const user = users.find(user => const user = users.find(user =>
@ -174,6 +174,10 @@ export const authOptions: NextAuthOptions = {
callbacks: { callbacks: {
// https://codevoweb.com/implement-authentication-with-nextauth-in-nextjs-14/ // https://codevoweb.com/implement-authentication-with-nextauth-in-nextjs-14/
async signIn({ user, account, profile }) { async signIn({ user, account, profile }) {
if (account.provider === 'credentials' && user?.static) {
return true;
}
var prisma = common.getPrismaClient(); var prisma = common.getPrismaClient();
console.log("[nextauth] signIn:", account.provider, user.email) console.log("[nextauth] signIn:", account.provider, user.email)
@ -247,7 +251,10 @@ export const authOptions: NextAuthOptions = {
session.user.role = token.role; session.user.role = token.role;
session.user.name = token.name || token.email; session.user.name = token.name || token.email;
} }
if (user?.impersonating) {
// Add flag to session if user is being impersonated
session.user.impersonating = true;
}
// if (session?.user) { // if (session?.user) {
// session.user.id = user.id; //duplicate // session.user.id = user.id; //duplicate
// } // }
@ -258,13 +265,13 @@ export const authOptions: NextAuthOptions = {
}; };
}, },
}, },
// pages: { pages: {
// signIn: "/auth/signin", signIn: "/auth/signin",
// signOut: "/auth/signout", signOut: "/auth/signout",
// error: "/message", // Error code passed in query string as ?error= error: "/message", // Error code passed in query string as ?error=
// verifyRequest: "/auth/verify-request", // (used for check email message) verifyRequest: "/auth/verify-request", // (used for check email message)
// newUser: null // If set, new users will be directed here on first sign in newUser: null // If set, new users will be directed here on first sign in
// }, },
} }
export default NextAuth(authOptions) export default NextAuth(authOptions)

View File

@ -27,6 +27,8 @@ export default async function handler(req, res) {
impersonating: true, // flag to indicate impersonation impersonating: true, // flag to indicate impersonation
originalUser: session.user // save the original user for later originalUser: session.user // save the original user for later
}; };
// Log the event (simplified example)
console.log(`Admin ${session.user} impersonated user ${userToImpersonate.email} on ${new Date().toISOString()}`);
// Here you would typically use some method to create a session server-side // Here you would typically use some method to create a session server-side
// For this example, we'll just send the impersonated session as a response // For this example, we'll just send the impersonated session as a response

View File

@ -1,12 +1,77 @@
import path from 'path';
import { promises as fs } from 'fs';
import express from 'express';
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import nc from 'next-connect'; import { promises as fs } from 'fs';
import path from 'path';
import multer from 'multer';
import sharp from 'sharp';
import { createRouter } from 'next-connect';
const handler = nc({
onError: (err, req, res, next) => { // Generalized Multer configuration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const subfolder = req.query.subfolder ? decodeURIComponent(req.query.subfolder as string) : 'default';
const uploadPath = path.join(process.cwd(), 'public/content', subfolder);
fs.mkdir(uploadPath, { recursive: true })
.then(() => cb(null, uploadPath))
.catch(cb);
},
filename: (req, file, cb) => {
const filename = decodeURIComponent(file.originalname); // Ensure the filename is correctly decoded
const prefix = req.body.prefix ? decodeURIComponent(req.body.prefix) : path.parse(filename).name;
cb(null, `${prefix}${path.extname(filename)}`);
}
});
const fileFilter = (req, file, cb) => {
// Accept PDFs only
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed!'), false);
}
};
const upload = multer({ storage, fileFilter });
const router = createRouter<NextApiRequest, NextApiResponse>();
router.use(upload.array('file'));
router.post((req, res) => {
console.log(req.files); // Log files to see if PDFs are included
if (req.files.length === 0) {
return res.status(400).json({ error: 'No files were uploaded.' });
}
// Process uploaded files, assume images are being resized and saved
res.json({ message: 'Files uploaded successfully', files: req.files });
});
router.get(async (req, res) => {
// Implement functionality to list files
const directory = path.join(process.cwd(), 'public/content', req.query.subfolder as string);
try {
const files = await fs.readdir(directory);
const imageUrls = files.map(file => `/content/${req.query.subfolder}/${file}`);
res.json({ imageUrls });
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', details: err.message });
}
});
router.delete(async (req, res) => {
// Implement functionality to delete a file
const filePath = path.join(process.cwd(), 'public/content', req.query.subfolder as string, req.query.file as string);
try {
await fs.unlink(filePath);
res.send('File deleted successfully.');
} catch (error) {
res.status(500).send('Failed to delete the file.');
}
});
export default router.handler({
onError: (err, req, res) => {
console.error(err.stack); console.error(err.stack);
res.status(500).end('Something broke!'); res.status(500).end('Something broke!');
}, },
@ -15,131 +80,8 @@ const handler = nc({
} }
}); });
handler.use((req: NextApiRequest, res: NextApiResponse, next) => {
const subfolder = req.query.subfolder as string;
const upload = createUploadMiddleware(subfolder).array('image');
upload(req, res, (err) => {
if (err) {
return res.status(500).json({ error: 'Failed to upload files.', details: err.message });
}
next();
});
});
handler.post((req: NextApiRequest, res: NextApiResponse) => {
// Process uploaded files
// Example response
res.json({ message: 'Files uploaded successfully', files: req.files });
});
handler.get((req: NextApiRequest, res: NextApiResponse) => {
// Handle listing files
//listFiles(req, res, req.subfolder);
listFiles(req, res, req.query.subfolder as string);
});
handler.delete((req: NextApiRequest, res: NextApiResponse) => {
// Handle deleting files
deleteFile(req, res, req.query.subfolder as string);
});
export const config = { export const config = {
api: { api: {
bodyParser: false, bodyParser: false,
}, },
}; };
export default handler;
// ------------------------------------------------------------
//handling file uploads
import multer from 'multer';
import sharp from 'sharp';
// Generalized Multer configuration
export const createUploadMiddleware = (folder: string) => {
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'public/content', folder);
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const prefix = req.body.prefix || path.parse(file.originalname).name;
cb(null, `${prefix}${path.extname(file.originalname)}`);
}
});
return multer({ storage });
};
async function processFiles(req, res, folder) {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded.' });
}
const uploadDir = path.join(process.cwd(), 'public/content', folder);
const thumbDir = path.join(uploadDir, "thumb");
if (!fs.existsSync(thumbDir)) {
fs.mkdirSync(thumbDir, { recursive: true });
}
try {
const processedFiles = await Promise.all(req.files.map(async (file) => {
const originalPath = path.join(uploadDir, file.filename);
const thumbPath = path.join(thumbDir, file.filename);
await sharp(file.path)
.resize({ width: 1920, fit: sharp.fit.inside, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(originalPath);
await sharp(file.path)
.resize(320, 320, { fit: sharp.fit.inside, withoutEnlargement: true })
.toFile(thumbPath);
fs.unlinkSync(file.path); // Remove temp file
return {
originalUrl: `/content/${folder}/${file.filename}`,
thumbUrl: `/content/${folder}/thumb/${file.filename}`
};
}));
res.json(processedFiles);
} catch (error) {
console.error('Error processing files:', error);
res.status(500).json({ error: 'Error processing files.' });
}
}
// List files in a directory
async function listFiles(req, res, folder) {
const directory = path.join(process.cwd(), 'public/content', folder);
try {
const files = await fs.promises.readdir(directory);
const imageUrls = files.map(file => `${req.protocol}://${req.get('host')}/content/${folder}/${file}`);
res.json({ imageUrls });
} catch (err) {
console.error('Error reading uploads directory:', err);
res.status(500).json({ error: 'Internal Server Error' });
}
}
// Delete a file
async function deleteFile(req, res, folder) {
const filename = req.query.file;
if (!filename) {
return res.status(400).send('Filename is required.');
}
try {
const filePath = path.join(process.cwd(), 'public/content', folder, filename);
await fs.unlink(filePath);
res.status(200).send('File deleted successfully.');
} catch (error) {
res.status(500).send('Failed to delete the file.');
}
}

View File

@ -161,7 +161,7 @@ export default async function handler(req, res) {
newPubs: newPubs, newPubs: newPubs,
placeName: assignment.shift.cartEvent.location.name, placeName: assignment.shift.cartEvent.location.name,
dateStr: common.getDateFormated(assignment.shift.startTime), dateStr: common.getDateFormated(assignment.shift.startTime),
time: common.formatTimeHHmm(assignment.shift.startTime), time: common.getTimeFormatted(assignment.shift.startTime),
sentDate: common.getDateFormated(new Date()) sentDate: common.getDateFormated(new Date())
}; };
@ -383,7 +383,7 @@ export default async function handler(req, res) {
email: pubsToSend[i].email, email: pubsToSend[i].email,
placeName: assignment.shift.cartEvent.location.name, placeName: assignment.shift.cartEvent.location.name,
dateStr: common.getDateFormated(assignment.shift.startTime), dateStr: common.getDateFormated(assignment.shift.startTime),
time: common.formatTimeHHmm(assignment.shift.startTime), time: common.getTimeFormatted(assignment.shift.startTime),
sentDate: common.getDateFormated(new Date()) sentDate: common.getDateFormated(new Date())
}; };
let results = emailHelper.SendEmailHandlebars( let results = emailHelper.SendEmailHandlebars(

View File

@ -1,6 +1,8 @@
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { authOptions } from './auth/[...nextauth]'
import { getServerSession } from "next-auth/next"
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { DayOfWeek, AvailabilityType } from '@prisma/client'; import { DayOfWeek, AvailabilityType, UserRole } from '@prisma/client';
const common = require('../../src/helpers/common'); const common = require('../../src/helpers/common');
const dataHelper = require('../../src/helpers/data'); const dataHelper = require('../../src/helpers/data');
const subq = require('../../prisma/bl/subqueries'); const subq = require('../../prisma/bl/subqueries');
@ -46,6 +48,9 @@ export default async function handler(req, res) {
let monthInfo = common.getMonthDatesInfo(day); let monthInfo = common.getMonthDatesInfo(day);
const searchText = req.query.searchText?.normalize('NFC'); const searchText = req.query.searchText?.normalize('NFC');
const sessionServer = await getServerSession(req, res, authOptions)
var isAdmin = sessionServer?.user.role == UserRole.ADMIN
try { try {
switch (action) { switch (action) {
case "initDb": case "initDb":
@ -137,7 +142,7 @@ export default async function handler(req, res) {
break; break;
case "getCalendarEvents": case "getCalendarEvents":
let events = await dataHelper.getCalendarEvents(req.query.publisherId, day); let events = await dataHelper.getCalendarEvents(req.query.publisherId, true, true, isAdmin);
res.status(200).json(events); res.status(200).json(events);
case "getPublisherInfo": case "getPublisherInfo":

View File

@ -1,33 +1,139 @@
const webPush = require('web-push') const webPush = require('web-push')
import common from '../../src/helpers/common';
//generate and store VAPID keys in .env.local if not already done
if (!process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY || !process.env.WEB_PUSH_PRIVATE_KEY) {
const { publicKey, privateKey } = webPush.generateVAPIDKeys()
console.log('VAPID keys generated:')
console.log('Public key:', publicKey)
console.log('Private key:', privateKey)
console.log('Store these keys in your .env.local file:')
console.log('NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY=', publicKey)
console.log('WEB_PUSH_PRIVATE_KEY=', privateKey)
process.exit(0)
}
webPush.setVapidDetails( webPush.setVapidDetails(
`mailto:${process.env.WEB_PUSH_EMAIL}`, `mailto:${process.env.WEB_PUSH_EMAIL}`,
process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY, process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY,
process.env.WEB_PUSH_PRIVATE_KEY process.env.WEB_PUSH_PRIVATE_KEY
) )
const Notification = (req, res) => { const Notification = async (req, res) => {
if (req.method == 'POST') { if (req.method == 'GET') {
const { subscription } = req.body res.statusCode = 200
res.setHeader('Allow', 'POST')
let subs = 0
if (req.query && req.query.id) {
const prisma = common.getPrismaClient();
const publisher = await prisma.publisher.findUnique({
where: { id: req.query.id },
select: { pushSubscription: true }
});
subs = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription.length : (publisher.pushSubscription ? 1 : 0);
res.end()
return
}
// send the public key in the response headers
//res.setHeader('Content-Type', 'text/plain')
res.send({ pk: process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY, subs })
res.end()
}
if (req.method == 'PUT') {
// store the subscription object in the database
// publisher.pushSubscription = subscription
const prisma = common.getPrismaClient();
const { subscription, id } = req.body
const publisher = await prisma.publisher.findUnique({
where: { id },
select: { pushSubscription: true }
});
webPush let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
.sendNotification( const index = subscriptions.findIndex(sub => sub.endpoint === subscription.endpoint);
subscription,
JSON.stringify({ title: 'Hello Web Push', message: 'Your web push notification is here!' }) if (index !== -1) {
) subscriptions[index] = subscription; // Update existing subscription
.then(response => { } else {
res.writeHead(response.statusCode, response.headers).end(response.body) subscriptions.push(subscription); // Add new subscription
}) }
.catch(err => {
if ('statusCode' in err) { await prisma.publisher.update({
res.writeHead(err.statusCode, err.headers).end(err.body) where: { id },
} else { data: { pushSubscription: subscriptions }
console.error(err) });
res.statusCode = 500 console.log('Subscription for publisher', id, 'updated:', subscription)
res.end() res.send({ subs: subscriptions.length })
} res.statusCode = 200
}) res.end()
}
if (req.method == 'DELETE') {
// remove the subscription object from the database
// publisher.pushSubscription = null
const prisma = common.getPrismaClient();
const { subscriptionId, id } = req.body;
const publisher = await prisma.publisher.findUnique({
where: { id },
select: { pushSubscription: true }
});
let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
try {
subscriptions = subscriptionId ? subscriptions.filter(sub => sub.endpoint !== subscriptionId) : [];
await prisma.publisher.update({
where: { id },
data: { pushSubscription: subscriptions }
});
} catch (e) {
console.log(e)
await prisma.publisher.update({
where: { id },
data: { pushSubscription: null }
});
}
console.log('Subscription for publisher', id, 'deleted')
res.send({ subs: subscriptions.length })
res.statusCode = 200
res.end()
}
if (req.method == 'POST') {//title = "ССС", message = "Ще получите уведомление по този начин.")
const { subscription, id, broadcast, title = 'ССОМ', message = 'Ще получавате уведомления така.', actions } = req.body
if (broadcast) {
await broadcastPush(title, message, actions)
res.statusCode = 200
res.end()
return
}
else if (id) {
await sendPush(id, title, message.actions)
res.statusCode = 200
res.end()
return
} else if (subscription) {
await webPush
.sendNotification(
subscription,
JSON.stringify({ title, message, actions })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
}
} else { } else {
res.statusCode = 405 res.statusCode = 405
res.end() res.end()
@ -35,3 +141,54 @@ const Notification = (req, res) => {
} }
export default Notification export default Notification
//export pushNotification(userId or email) for use in other files
export const sendPush = async (id, title, message, actions) => {
const prisma = common.getPrismaClient();
const publisher = await prisma.publisher.findUnique({
where: { id }
})
if (!publisher.pushSubscription) {
console.log('No push subscription found for publisher', id)
return
}
await webPush
.sendNotification(
publisher.pushSubscription,
JSON.stringify({ title, message, actions })
)
.then(response => {
console.log('Push notification sent to publisher', id)
})
.catch(err => {
console.error('Error sending push notification to publisher', id, ':', err)
})
}
//export breoadcastNotification for use in other files
export const broadcastPush = async (title, message, actions) => {
const prisma = common.getPrismaClient();
const publishers = await prisma.publisher.findMany({
where: { pushSubscription: { not: null } }
})
for (const publisher of publishers) {
if (Array.isArray(publisher.pushSubscription) && publisher.pushSubscription.length) {
for (const subscription of publisher.pushSubscription) {
await webPush.sendNotification(
subscription, // Here subscription is each individual subscription object
JSON.stringify({ title, message, actions })
)
.then(response => {
console.log('Push notification sent to device', subscription.endpoint, 'of publisher', publisher.id);
})
.catch(err => {
console.error('Error sending push notification to device', subscription.endpoint, 'of publisher', publisher.id, ':', err);
// Optionally handle failed subscriptions, e.g., remove outdated or invalid subscriptions
});
}
} else {
console.log('No valid subscriptions found for publisher', publisher.id);
}
}
}

View File

@ -58,10 +58,7 @@ export default function SignIn({ csrfToken }) {
<div className="page"> <div className="page">
<div className="signin"> <div className="signin">
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-100">
{/* Page Title */}
<h1 className="text-2xl font-bold text-gray-900 mt-6">Вход</h1> <h1 className="text-2xl font-bold text-gray-900 mt-6">Вход</h1>
{/* Section for Social Sign-On Providers */}
<div className="mt-8 w-full max-w-md px-4 py-8 bg-white shadow rounded-lg"> <div className="mt-8 w-full max-w-md px-4 py-8 bg-white shadow rounded-lg">
{/* <h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Sign in with a Social Media Account</h2> */} {/* <h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Sign in with a Social Media Account</h2> */}
<button onClick={() => signIn('google', { callbackUrl: '/' })} <button onClick={() => signIn('google', { callbackUrl: '/' })}
@ -71,22 +68,16 @@ export default function SignIn({ csrfToken }) {
Влез чрез Google Влез чрез Google
</button> </button>
{/* Apple Sign-In Button */} {/* Apple Sign-In Button */}
<button onClick={() => signIn('apple', { callbackUrl: '/' })} {/* <button onClick={() => signIn('apple', { callbackUrl: '/' })}
className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"> className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<img loading="lazy" height="24" width="24" alt="Apple logo" <img loading="lazy" height="24" width="24" alt="Apple logo"
src="https://authjs.dev/img/providers/apple.svg" className="mr-2" /> src="https://authjs.dev/img/providers/apple.svg" className="mr-2" />
Влез чрез Apple Влез чрез Apple
</button> </button> */}
{/* Add more buttons for other SSO providers here in similar style */}
</div> </div>
{/* Divider (Optional) */}
<div className="w-full max-w-xs mt-8 mb-8"> <div className="w-full max-w-xs mt-8 mb-8">
<hr className="border-t border-gray-300" /> <hr className="border-t border-gray-300" />
</div> </div>
{/* Local Account Email and Password Form */}
<div className="w-full max-w-md mt-8 mb-8 px-4 py-8 bg-white shadow rounded-lg"> <div className="w-full max-w-md mt-8 mb-8 px-4 py-8 bg-white shadow rounded-lg">
<h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Влез с локален акаунт</h2> <h2 className="text-center text-lg font-semibold text-gray-900 mb-4">Влез с локален акаунт</h2>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@ -132,9 +123,11 @@ export default function SignIn({ csrfToken }) {
// This gets called on every request // This gets called on every request
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const csrfToken = await getCsrfToken(context);
return { return {
props: { props: {
csrfToken: await getCsrfToken(context), ...(csrfToken ? { csrfToken } : {}),
}, },
}; };
} }

View File

@ -15,6 +15,9 @@ import { toast } from 'react-toastify';
import ProtectedRoute from '../../../components/protectedRoute'; import ProtectedRoute from '../../../components/protectedRoute';
import ConfirmationModal from '../../../components/ConfirmationModal'; import ConfirmationModal from '../../../components/ConfirmationModal';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
// import notify api
import { sendPush, broadcastPush } from '../../api/notify';
const { DateTime } = require('luxon');
// import { FaPlus, FaCogs, FaTrashAlt, FaSpinner } from 'react-icons/fa'; // Import FontAwesome icons // import { FaPlus, FaCogs, FaTrashAlt, FaSpinner } from 'react-icons/fa'; // Import FontAwesome icons
@ -544,7 +547,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
var dayName = common.DaysOfWeekArray[value.getDayEuropean()]; var dayName = common.DaysOfWeekArray[value.getDayEuropean()];
const cartEvent = events.find(event => event.dayofweek == dayName); const cartEvent = events.find(event => event.dayofweek == dayName);
lastShift = { lastShift = {
endTime: new Date(value.setHours(9, 0, 0, 0)), endTime: DateTime.fromJSDate(value).setZone('Europe/Sofia', { keepLocalTime: true }).set({ hour: 9 }).toJSDate(),
cartEventId: cartEvent.id cartEventId: cartEvent.id
}; };
} }
@ -733,7 +736,19 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span> <span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span>
<span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span> <span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span>
<span tooltip="участия миналия месец" title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span> <span tooltip="участия миналия месец" title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span>
<button tooltip="желани участия този месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button> <button tooltip="желани участия на месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
<button tooltip="push" title="push" className={`badge py-1 px-2 rounded-md text-xs bg-red-100`}
onClick={async () => {
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ broadcast: true, message: "Тестово съобщение", title: "Това е тестово съобщение от https://sofia.mwitnessing.com" })
})
}}
>+</button>
</div> </div>
</li> </li>
); );

View File

@ -64,13 +64,23 @@ export const getServerSideProps = async (context) => {
} }
}); });
if (!item) { if (!item) {
const user = context.req.session.user; const user = context.req.session?.user;
if (!user) {
return {
// redirect to '/auth/signin'. assure it is not relative path
redirect: {
destination: process.env.NEXT_PUBLIC_PUBLIC_URL + "/auth/signin",
permanent: false,
}
}
}
const message = encodeURIComponent(`Този имейл (${user?.email}) не е регистриран. Моля свържете се с администратора.`);
return { return {
redirect: { redirect: {
destination: '/message?message=Този имейл (' + user.email + ') не е регистриран. Моля свържете се с администратора.', destination: `/message?message=${message}`,
permanent: false, permanent: false,
}, },
} };
} }
// item.allShifts = item.assignments.map((a: Assignment[]) => a.shift); // item.allShifts = item.assignments.map((a: Assignment[]) => a.shift);

View File

@ -11,7 +11,6 @@ import * as XLSX from "xlsx";
// import { Table } from "react-bootstrap"; // import { Table } from "react-bootstrap";
import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external"; import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";
import moment from 'moment';
// import { DatePicker } from '@mui/x-date-pickers'; !! CAUSERS ERROR ??? // import { DatePicker } from '@mui/x-date-pickers'; !! CAUSERS ERROR ???
// import { DatePicker } from '@mui/x-date-pickers/DatePicker'; // import { DatePicker } from '@mui/x-date-pickers/DatePicker';

View File

@ -60,7 +60,7 @@ export default function MySchedulePage({ assignments }) {
<div className="container "> <div className="container ">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-center my-4">Моите смени</h1> <h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-center my-4">Моите смени</h1>
<div className="space-y-4"> <div className="space-y-4">
{assignments && assignments.map((assignment) => ( {assignments && assignments.length > 0 ? (assignments.map((assignment) => (
<div key={assignment.dateStr + assignments.indexOf(assignment)} className="bg-white shadow overflow-hidden rounded-lg"> <div key={assignment.dateStr + assignments.indexOf(assignment)} className="bg-white shadow overflow-hidden rounded-lg">
<div className="px-4 py-5 sm:px-6"> <div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">{assignment.dateStr}</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">{assignment.dateStr}</h3>
@ -117,7 +117,13 @@ export default function MySchedulePage({ assignments }) {
</dl> </dl>
</div> </div>
</div> </div>
))} ))) :
<div className="bg-white shadow overflow-hidden rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">За сега нямате бъдещи назначени смени. Можете да проверите дали вашите възножности са актуални.</h3>
</div>
</div>
}
</div> </div>
</div> </div>
<Modal isOpen={isModalOpen} <Modal isOpen={isModalOpen}
@ -168,10 +174,12 @@ export const getServerSideProps = async (context) => {
} }
const prisma = common.getPrismaClient(); const prisma = common.getPrismaClient();
const monthInfo = common.getMonthInfo(new Date()); let today = new Date();
//minus 1 day from the firstMonday to get the last Sunday today.setHours(0, 0, 0, 0);
const lastSunday = new Date(monthInfo.firstMonday); // const monthInfo = common.getMonthInfo(today);
lastSunday.setDate(lastSunday.getDate() - 1); // //minus 1 day from the firstMonday to get the last Sunday
// const lastSunday = new Date(monthInfo.firstMonday);
// lastSunday.setDate(lastSunday.getDate() - 1);
const publisher = await prisma.publisher.findUnique({ const publisher = await prisma.publisher.findUnique({
where: { where: {
id: session.user.id, id: session.user.id,
@ -179,7 +187,7 @@ export const getServerSideProps = async (context) => {
some: { some: {
shift: { shift: {
startTime: { startTime: {
gte: lastSunday, gte: today,
}, },
}, },
}, },
@ -208,7 +216,7 @@ export const getServerSideProps = async (context) => {
}, },
}); });
const assignments = publisher?.assignments.filter(a => a.shift.startTime >= lastSunday && a.shift.isPublished) || []; const assignments = publisher?.assignments.filter(a => a.shift.startTime >= today && a.shift.isPublished) || [];
const transformedAssignments = assignments?.sort((a, b) => a.shift.startTime - b.shift.startTime) const transformedAssignments = assignments?.sort((a, b) => a.shift.startTime - b.shift.startTime)

View File

@ -2,20 +2,22 @@ import React from 'react';
import Layout from "../components/layout"; import Layout from "../components/layout";
import FeedbackForm from "../components/reports/FeedbackForm"; import FeedbackForm from "../components/reports/FeedbackForm";
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import ProtectedRoute from "../components/protectedRoute";
const ContactsPage = () => { const ContactsPage = () => {
const t = useTranslations('common'); const t = useTranslations('common');
return ( return (
<Layout> <Layout>
<div className="mx-auto my-8 p-6 max-w-4xl bg-white rounded-lg shadow-md"> <ProtectedRoute>
<h1 className="text-2xl font-bold text-gray-800 mb-4">{t('appNameLong') - t('contacts')}</h1> <div className="mx-auto my-8 p-6 max-w-4xl bg-white rounded-lg shadow-md">
<ul className="list-disc pl-5"> <h1 className="text-2xl font-bold text-gray-800 mb-4">{t('appNameLong') - t('contacts')}</h1>
<li className="text-gray-700 mb-2">Янко Ванчев - <a href="tel:+359878224467" className="text-blue-500 hover:text-blue-600">+359 878 22 44 67</a></li> <ul className="list-disc pl-5">
<li className="text-gray-700">Крейг Смит - <a href="tel:+359878994573" className="text-blue-500 hover:text-blue-600">+359 878 994 573</a></li> <li className="text-gray-700 mb-2">Янко Ванчев - <a href="tel:+359878224467" className="text-blue-500 hover:text-blue-600">+359 878 22 44 67</a></li>
</ul> <li className="text-gray-700">Крейг Смит - <a href="tel:+359878994573" className="text-blue-500 hover:text-blue-600">+359 878 994 573</a></li>
<div className="text-gray-700 pl-4 py-5">Електронна поща: <a href="mailto:specialnosvidetelstvanesofia@gmail.com" className="text-blue-500 hover:text-blue-600">specialnosvidetelstvanesofia@gmail.com</a></div> </ul>
<div className="text-gray-700 pl-4 py-5">Електронна поща: <a href="mailto:specialnosvidetelstvanesofia@gmail.com" className="text-blue-500 hover:text-blue-600">specialnosvidetelstvanesofia@gmail.com</a></div>
{/* <div className="mt-6"> {/* <div className="mt-6">
<h3 className="text-lg font-semibold text-gray-800 mb-3">Социални мрежи</h3> <h3 className="text-lg font-semibold text-gray-800 mb-3">Социални мрежи</h3>
<div className="flex space-x-4"> <div className="flex space-x-4">
<a href="#" className="text-blue-500 hover:text-blue-600"> <a href="#" className="text-blue-500 hover:text-blue-600">
@ -25,13 +27,13 @@ const ContactsPage = () => {
</a> </a>
</div> </div>
</div > */ </div > */
} }
{/* <a href="https://t.me/mwHitnessing_bot" className="inline-flex items-center ml-4" target="_blank"> {/* <a href="https://t.me/mwHitnessing_bot" className="inline-flex items-center ml-4" target="_blank">
<img src="styles/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" /> <img src="styles/icons/telegram-svgrepo-com.svg" alt="Телеграм" width="32" height="32" className="align-middle" />
<span className="align-middle">Телеграм</span> <span className="align-middle">Телеграм</span>
</a> */} </a> */}
</div > </div >
</ProtectedRoute>
</Layout > </Layout >
); );
}; };

View File

@ -15,13 +15,14 @@ import { getServerSession } from "next-auth/next"
import PublisherSearchBox from '../components/publisher/PublisherSearchBox'; import PublisherSearchBox from '../components/publisher/PublisherSearchBox';
import PublisherInlineForm from '../components/publisher/PublisherInlineForm'; import PublisherInlineForm from '../components/publisher/PublisherInlineForm';
import CartEventForm from "components/cartevent/CartEventForm";
interface IProps { interface IProps {
initialItems: Availability[]; initialItems: Availability[];
initialUserId: string; initialUserId: string;
} }
export default function IndexPage({ initialItems, initialUserId }: IProps) { export default function IndexPage({ initialItems, initialUserId, cartEvents }: IProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const [userName, setUserName] = useState(session?.user?.name); const [userName, setUserName] = useState(session?.user?.name);
const [userId, setUserId] = useState(initialUserId); const [userId, setUserId] = useState(initialUserId);
@ -68,7 +69,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
return ( return (
<Layout> <Layout>
<ProtectedRoute deniedMessage=""> <ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER, UserRole.EXTERNAL]} deniedMessage="">
<h1 className="pt-2 pb-1 text-xl font-bold text-center">Графика на {userName}</h1> <h1 className="pt-2 pb-1 text-xl font-bold text-center">Графика на {userName}</h1>
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=""> <ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<PublisherSearchBox selectedId={userId} infoText="" onChange={handleUserSelection} /> <PublisherSearchBox selectedId={userId} infoText="" onChange={handleUserSelection} />
@ -78,7 +79,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
<div className="text-center font-bold pb-3 xs:pb-1"> <div className="text-center font-bold pb-3 xs:pb-1">
<PublisherInlineForm publisherId={userId} /> <PublisherInlineForm publisherId={userId} />
</div> </div>
<AvCalendar publisherId={userId} events={events} selectedDate={new Date()} /> <AvCalendar publisherId={userId} events={events} selectedDate={new Date()} cartEvents={cartEvents} />
</div> </div>
</div> </div>
</ProtectedRoute> </ProtectedRoute>
@ -119,7 +120,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
// ...item, // ...item,
// startTime: item.startTime.toISOString(), // startTime: item.startTime.toISOString(),
// endTime: item.endTime.toISOString(), // endTime: item.endTime.toISOString(),
// name: common.getTimeFomatted(item.startTime) + "-" + common.getTimeFomatted(item.endTime), // name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime),
// //endDate can be null // //endDate can be null
// endDate: item.endDate ? item.endDate.toISOString() : null, // endDate: item.endDate ? item.endDate.toISOString() : null,
// type: 'availability', // type: 'availability',
@ -175,7 +176,7 @@ export default function IndexPage({ initialItems, initialUserId }: IProps) {
// endTime: item.shift.endTime.toISOString(), // endTime: item.shift.endTime.toISOString(),
// // name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "), // // name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "),
// //name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "), // //name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "),
// name: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)), // name: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
// type: 'assignment', // type: 'assignment',
// //delete shift object // //delete shift object
// shift: null, // shift: null,
@ -193,29 +194,70 @@ export const getServerSideProps = async (context) => {
req: context.req, req: context.req,
allowedRoles: [/* ...allowed roles... */] allowedRoles: [/* ...allowed roles... */]
}); });
const session = await getSession(context); // const session = await getSession(context);
const sessionServer = await getServerSession(context.req, context.res, authOptions) const sessionServer = await getServerSession(context.req, context.res, authOptions)
if (!session) { return { props: {} } } if (!sessionServer) {
const role = session?.user.role; return {
console.log("server role: " + role); redirect: {
const userId = session?.user.id; destination: '/auth/signin',
permanent: false,
},
};
}
var items = await dataHelper.getCalendarEvents(session.user.id); const role = sessionServer?.user.role;
console.log("server role: " + role);
const userId = sessionServer?.user.id;
var isAdmin = sessionServer?.user.role == UserRole.ADMIN;//role.localeCompare(UserRole.ADMIN) === 0;
var items = await dataHelper.getCalendarEvents(userId, true, true, isAdmin);
// common.convertDatesToISOStrings(items); // common.convertDatesToISOStrings(items);
//serializable dates //serializable dates
items = items.map(item => ({ items = items.map(item => {
...item, const updatedItem = {
startTime: item.startTime.toISOString(), ...item,
endTime: item.endTime.toISOString(), startTime: item.startTime.toISOString(),
date: item.date.toISOString(), endTime: item.endTime.toISOString(),
})); date: item.date.toISOString(),
name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime)
};
if (updatedItem.shift) {
updatedItem.shift = {
...updatedItem.shift,
startTime: updatedItem.shift.startTime.toISOString(),
endTime: updatedItem.shift.endTime.toISOString()
};
}
return updatedItem;
});
// log first availability startTime to verify timezone and UTC conversion
console.log("First availability startTime: " + items[0]?.startTime);
console.log("First availability startTime: " + items[0]?.startTime.toLocaleString());
const prisma = common.getPrismaClient();
let cartEvents = await prisma.cartEvent.findMany({
where: {
isActive: true,
},
select: {
id: true,
startTime: true,
endTime: true,
dayofweek: true,
shiftDuration: true,
}
});
cartEvents = common.convertDatesToISOStrings(cartEvents);
return { return {
props: { props: {
initialItems: items, initialItems: items,
userId: session?.user.id, userId: sessionServer?.user.id,
cartEvents: cartEvents,
// messages: (await import(`../content/i18n/${context.locale}.json`)).default // messages: (await import(`../content/i18n/${context.locale}.json`)).default
}, },
}; };

View File

@ -1,13 +1,16 @@
import React from 'react'; import React from 'react';
import Layout from "../components/layout"; import Layout from "../components/layout";
import FeedbackForm from "../components/reports/FeedbackForm"; import FeedbackForm from "../components/reports/FeedbackForm";
import ProtectedRoute from "../components/protectedRoute";
const ContactsPage = () => { const ContactsPage = () => {
return ( return (
<Layout> <Layout>
<div className="h-5/6 grid place-items-center"> <ProtectedRoute>
<FeedbackForm /> <div className="h-5/6 grid place-items-center">
</div> <FeedbackForm />
</div>
</ProtectedRoute>
</Layout > </Layout >
); );
}; };

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Layout from "../components/layout"; import Layout from "../components/layout";
import ProtectedRoute from "../components/protectedRoute";
const PDFViewerPage = () => { const PDFViewerPage = () => {
const [language, setLanguage] = useState('bg'); // default language is Bulgarian const [language, setLanguage] = useState('bg'); // default language is Bulgarian
@ -25,61 +26,64 @@ const PDFViewerPage = () => {
}; };
return ( return (
<Layout><script src="https://mozilla.github.io/pdf.js/dist/pdf.js"></script> <Layout>
<h1 className="text-3xl font-bold">Напътствия</h1> <ProtectedRoute>
<div className="guidelines-section mb-5 p-4 bg-gray-100 rounded-lg"> <script src="https://mozilla.github.io/pdf.js/dist/pdf.js"></script>
<h2 className="text-2xl font-semibold mb-3">Важни напътствия за службата</h2> <h1 className="text-3xl font-bold">Напътствия</h1>
<ol className="list-decimal list-inside"> <div className="guidelines-section mb-5 p-4 bg-gray-100 rounded-lg">
<li><strong>Покани за Възпоменанието:</strong> За кампанията ще използваме покани без конкретен адрес. Ще насочваме хората към най-близкото за тях място, чрез сайта или като попълним поканата.</li> <h2 className="text-2xl font-semibold mb-3">Важни напътствия за службата</h2>
<ol className="list-decimal list-inside">
<li><strong>Покани за Възпоменанието:</strong> За кампанията ще използваме покани без конкретен адрес. Ще насочваме хората към най-близкото за тях място, чрез сайта или като попълним поканата.</li>
<li><strong>Щандове:</strong> Предлагаме следното: <li><strong>Щандове:</strong> Предлагаме следното:
<ul className="list-disc list-inside ml-4"> <ul className="list-disc list-inside ml-4">
<li>Да има известно разстояние между нас и щандовете. Целта е да оставим хората свободно да се доближат до количките и ако някой прояви интерес може да се приближим.</li> <li>Да има известно разстояние между нас и щандовете. Целта е да оставим хората свободно да се доближат до количките и ако някой прояви интерес може да се приближим.</li>
<li>Когато сме двама или трима може да стоим заедно. Ако сме четирима би било хубаво да се разделим по двама на количка и количките да са на известно разстояние една от друга.</li> <li>Когато сме двама или трима може да стоим заедно. Ако сме четирима би било хубаво да се разделим по двама на количка и количките да са на известно разстояние една от друга.</li>
</ul> </ul>
</li> </li>
<li><strong>Безопасност:</strong> Нека се страем зад нас винаги да има защита или препятствие за недобронамерени хора.</li> <li><strong>Безопасност:</strong> Нека се страем зад нас винаги да има защита или препятствие за недобронамерени хора.</li>
<li><strong>Плакати:</strong> Моля при придвижване на количките да слагате плакатите така, че илюстрацията да се вижда, когато калъфа е сложен. Целта е снимките да не се търкат в количката, защото се повреждат.</li> <li><strong>Плакати:</strong> Моля при придвижване на количките да слагате плакатите така, че илюстрацията да се вижда, когато калъфа е сложен. Целта е снимките да не се търкат в количката, защото се повреждат.</li>
<li><strong>Литература:</strong> При проявен интерес на чужд език, използвайте списанията и трактатите на други езици в папките.</li> <li><strong>Литература:</strong> При проявен интерес на чужд език, използвайте списанията и трактатите на други езици в папките.</li>
<li><strong>График:</strong> Моля да ни изпратите вашите предпочитания до 23-то число на месеца като използвате меню <a href='/dash'> Възможности</a></li> <li><strong>График:</strong> Моля да ни изпратите вашите предпочитания до 23-то число на месеца като използвате меню <a href='/dash'> Възможности</a></li>
<li><strong>Случки:</strong> Ако сте имали хубави случки на количките, моля пишете ни.</li> <li><strong>Случки:</strong> Ако сте имали хубави случки на количките, моля пишете ни.</li>
</ol> </ol>
</div>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
<div className="my-4 flex items-center">
{languages.map((lang, index) => (
<React.Fragment key={lang.code}>
{index > 0 && <div className="bg-gray-400 w-px h-6"></div>} {/* Vertical line separator */}
<button
onClick={() => setLanguage(lang.code)}
className={`text-lg py-2 px-4 ${language === lang.code ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800 hover:bg-blue-500 hover:text-white'} ${index === 0 ? 'rounded-l-full' : index === languages.length - 1 ? 'rounded-r-full' : ''}`}
>
{lang.label}
</button>
</React.Fragment>
))}
</div> </div>
<p className="p-2 pb-5">
<a href={pdfFile} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали Напътствията
</a>
</p>
<div style={{ width: 'calc(100% - 1rem)', height: '100%', margin: '0 0' }}> <div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
<embed src={pdfFile} type="application/pdf" style={{ width: '100%', height: '100%' }} /> <div className="my-4 flex items-center">
{/* <object data={pdfFile} type="application/pdf" page="2" style={{ width: '100%', height: '100%' }}> {languages.map((lang, index) => (
<React.Fragment key={lang.code}>
{index > 0 && <div className="bg-gray-400 w-px h-6"></div>} {/* Vertical line separator */}
<button
onClick={() => setLanguage(lang.code)}
className={`text-lg py-2 px-4 ${language === lang.code ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800 hover:bg-blue-500 hover:text-white'} ${index === 0 ? 'rounded-l-full' : index === languages.length - 1 ? 'rounded-r-full' : ''}`}
>
{lang.label}
</button>
</React.Fragment>
))}
</div>
<p className="p-2 pb-5">
<a href={pdfFile} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали Напътствията
</a>
</p>
<div style={{ width: 'calc(100% - 1rem)', height: '100%', margin: '0 0' }}>
<embed src={pdfFile} type="application/pdf" style={{ width: '100%', height: '100%' }} />
{/* <object data={pdfFile} type="application/pdf" page="2" style={{ width: '100%', height: '100%' }}>
<p>Your browser does not support PDFs. Please download the PDF to view it: <a href={pdfFile}>Свали PDF файла</a>.</p> <p>Your browser does not support PDFs. Please download the PDF to view it: <a href={pdfFile}>Свали PDF файла</a>.</p>
<p>Вашият браузър не поддържа PDFs файлове. Моля свалете файла за да го разгледате: <a href={pdfFile}>Свали PDF файла</a>.</p> <p>Вашият браузър не поддържа PDFs файлове. Моля свалете файла за да го разгледате: <a href={pdfFile}>Свали PDF файла</a>.</p>
</object> */} </object> */}
</div>
{/* <iframe src={`https://docs.google.com/gview?url=${baseUrl}${pdfFile}&embedded=true`} style={{ width: '100%', height: '100%' }} frameBorder="0"></iframe> */}
</div> </div>
{/* <iframe src={`https://docs.google.com/gview?url=${baseUrl}${pdfFile}&embedded=true`} style={{ width: '100%', height: '100%' }} frameBorder="0"></iframe> */} </ProtectedRoute>
</Layout>
</div>
</Layout >
); );
}; };

View File

@ -3,8 +3,9 @@ import Layout from "../components/layout";
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { url } from 'inspector'; import { url } from 'inspector';
import ProtectedRoute, { serverSideAuth } from "/components/protectedRoute"; import ProtectedRoute from "../components/protectedRoute";
import axiosInstance from '../src/axiosSecure'; import axiosInstance from '../src/axiosSecure';
import { UserRole } from "@prisma/client";
const PDFViewerPage = ({ pdfFiles }) => { const PDFViewerPage = ({ pdfFiles }) => {
@ -22,17 +23,23 @@ const PDFViewerPage = ({ pdfFiles }) => {
const handleFileUpload = async (event) => { const handleFileUpload = async (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
//utf-8 encoding
// const formData = new FormData();
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); // formData.append('file', file);
const newFile = new File([file], encodeURI(file.name), { type: file.type });
formData.append('file', newFile);
const subfolder = 'permits'; // Change this as needed based on your subfolder structure const subfolder = 'permits'; // Change this as needed based on your subfolder structure
try { try {
const response = await axiosInstance.post(`/api/content/${subfolder}`, formData, { const response = await axiosInstance.post(`/api/content/${subfolder}`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data',
// 'Content-Encoding': 'utf-8'
} }
}); });
setFiles([...files, response.data]); const newFiles = response.data.files.map(file => ({ name: decodeURIComponent(file.originalname), url: file.path }));
setFiles([...files, ...newFiles]);
} catch (error) { } catch (error) {
console.error('Error uploading file:', error); console.error('Error uploading file:', error);
} }
@ -41,38 +48,59 @@ const PDFViewerPage = ({ pdfFiles }) => {
return ( return (
<Layout> <Layout>
<h1 className="text-3xl font-bold p-4 pt-8">Разрешителни</h1>
<ProtectedRoute> <ProtectedRoute>
<input type="file" onChange={handleFileUpload} className="mb-4" /> <h1 className="text-3xl font-bold p-4 pt-8">Разрешителни</h1>
{files.map((file, index) => ( <ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<div key={file.name} className="py-2"> <div className="border border-blue-500 p-4 rounded shadow-md">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'> <div className="mb-6">
{file.name} <p className="text-lg mb-2">За да качите файл кликнете на бутона по-долу и изберете файл от вашия компютър.</p>
</a> <input type="file" onChange={handleFileUpload} className="block w-full text-sm text-gray-600
<button onClick={() => handleFileDelete(file.name)} className="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded"> file:mr-4 file:py-2 file:px-4
изтрий file:border-0
</button> file:text-sm file:font-semibold
</div> file:bg-blue-500 file:text-white
))} hover:file:bg-blue-600"/>
</ProtectedRoute>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
{pdfFiles.map((file, index) => (
<> <p className="pt-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали: {file.name}
</a>
</p>
<div style={{ width: 'calc(100% - 1rem)', height: '100%' }} className='py-2'>
< object data={file.url} type="application/pdf" style={{ width: '100%', height: '100%' }}>
<p>Вашият браузър не поддържа PDFs файлове. Моля свалете файла за да го разгледате: <a href={file.url}>Свали {file.name}</a>.</p>
<p>Your browser does not support PDFs. Please download the PDF to view it: <a href={file.url}> {file.name}</a>.</p>
</object>
</div> </div>
</> <div>
))} <h3 className="text-lg font-semibold mb-2">Съществуващи файлове:</h3>
</div> {files.length > 0 ? (
files.map((file, index) => (
<div key={index} className="flex items-center justify-between mb-2 p-2 hover:bg-blue-50 rounded">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target="_blank" rel="noopener noreferrer">
{file.name}
</a>
<button onClick={() => handleFileDelete(file.name)} className="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
изтрий
</button>
</div>
))
) : (
<p className="text-gray-500">Няма качени файлове.</p>
)}
</div>
</div>
</ProtectedRoute>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
{pdfFiles.map((file, index) => (
<> <p className="pt-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали: {file.name}
</a>
</p>
<div style={{ width: 'calc(100% - 1rem)', height: '100%' }} className='py-2'>
< object data={file.url} type="application/pdf" style={{ width: '100%', height: '100%' }}>
<p>Вашият браузър не поддържа PDFs файлове. Моля свалете файла за да го разгледате: <a href={file.url}>Свали {file.name}</a>.</p>
<p>Your browser does not support PDFs. Please download the PDF to view it: <a href={file.url}> {file.name}</a>.</p>
</object>
</div>
</>
))}
</div>
</ProtectedRoute>
</Layout > </Layout >
); );
}; };
@ -102,5 +130,4 @@ export const getServerSideProps = async (context) => {
} }
}; };
} }
// export const getServerSideProps = async (context) => {

View File

@ -0,0 +1,36 @@
UPDATE `Availability`
SET
`startTime` = ADDTIME(`startTime`, '01:00:00'),
`endTime` = ADDTIME(`endTime`, '01:00:00'),
`name` = CONCAT(`name`, ' (DHT)')
WHERE
`startTime` LIKE '%05:00%' -- this is 9:00; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%06:30%' -- this is 10:30; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%08:00%' -- this is 12:00; -4 hours difference, where -3 is expected
OR `startTime` LIKE '%09:30%' -- this is 13:30 UTC
OR `startTime` LIKE '%11:00%' -- this is 15:00 UTC
OR `startTime` LIKE '%12:30%' -- this is 16:30 UTC
OR `startTime` LIKE '%14:00%' -- this is 18:00 UTC
SELECT
p.id,
p.firstName,
p.lastName,
a.id,
name,
dayofweek,
startTime,
endTime,
dayOfMonth,
weekOfMonth,
isFromPreviousAssignment,
isFromPreviousMonth,
endDate,
repeatWeekly,
dateOfEntry,
parentAvailabilityId
FROM
`Availability` a
left join Publisher p on p.id = publisherId
WHERE
name LIKE '%(DHT)%'

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Publisher` ADD COLUMN `pushSubscription` JSON NULL;

View File

@ -123,6 +123,7 @@ model Publisher {
Message Message[] Message Message[]
EventLog EventLog[] EventLog EventLog[]
lastLogin DateTime? lastLogin DateTime?
pushSubscription Json?
} }
model Availability { model Availability {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -2,6 +2,16 @@
"theme_color": "#ffffff", "theme_color": "#ffffff",
"background_color": "#e36600", "background_color": "#e36600",
"icons": [ "icons": [
{
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image&#x2F;png"
},
{
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image&#x2F;png"
},
{ {
"purpose": "maskable", "purpose": "maskable",
"sizes": "512x512", "sizes": "512x512",

View File

@ -32,8 +32,10 @@ const PROTOCOL = process.env.PROTOCOL;
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST; const HOST = process.env.HOST;
const dev = process.env.NODE_ENV !== "production"; // production and test environments run with optimized build
const dev = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test';
const nextApp = next({ dev }); const nextApp = next({ dev });
const nextHandler = nextApp.getRequestHandler(); const nextHandler = nextApp.getRequestHandler();
console.log("process.env.PROTOCOL = ", process.env.PROTOCOL); console.log("process.env.PROTOCOL = ", process.env.PROTOCOL);
process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; //NEXTAUTH_URL mandatory for next-auth process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; //NEXTAUTH_URL mandatory for next-auth
@ -41,13 +43,17 @@ console.log("process.env.NEXT_PUBLIC_PUBLIC_URL = ", process.env.NEXT_PUBLIC_PUB
console.log("process.env.NEXTAUTH_URL = ", process.env.NEXTAUTH_URL); console.log("process.env.NEXTAUTH_URL = ", process.env.NEXTAUTH_URL);
console.log("process.env.PORT = ", process.env.PORT); console.log("process.env.PORT = ", process.env.PORT);
console.log("process.env.TELEGRAM_BOT = ", process.env.TELEGRAM_BOT); console.log("process.env.TELEGRAM_BOT = ", process.env.TELEGRAM_BOT);
console.log("process.env.DATABASE_URL = ", process.env.DATABASE_URL);
console.log("process.env.DATABASE = ", process.env.DATABASE); console.log("process.env.DATABASE = ", process.env.DATABASE);
console.log("process.env.APPLE_APP_ID = ", process.env.APPLE_APP_ID); console.log("process.env.APPLE_APP_ID = ", process.env.APPLE_APP_ID);
logger.info("App started on " + process.env.PROTOCOL + "://" + process.env.HOST + ":" + process.env.PORT + ""); logger.info("App started on " + process.env.PROTOCOL + "://" + process.env.HOST + ":" + process.env.PORT + "");
logger.info("process.env.NEXT_PUBLIC_PUBLIC_URL = ", process.env.NEXT_PUBLIC_PUBLIC_URL);
logger.info("process.env.NEXTAUTH_URL = ", process.env.NEXTAUTH_URL);
logger.info("process.env.PORT = ", process.env.PORT);
logger.info("process.env.DATABASE = ", process.env.DATABASE);
logger.info("process.env.GIT_COMMIT_ID = " + process.env.GIT_COMMIT_ID); logger.info("process.env.GIT_COMMIT_ID = " + process.env.GIT_COMMIT_ID);
logger.info("process.env.APP_ENV = " + process.env.APP_ENV); logger.info("process.env.APP_ENV = " + process.env.APP_ENV);
logger.info("process.env.ENV_ENV = " + process.env.ENV_ENV);
logger.info("process.env.NODE_ENV = " + process.env.NODE_ENV); logger.info("process.env.NODE_ENV = " + process.env.NODE_ENV);
logger.info("process.env.APPLE_APP_ID = " + process.env.APPLE_APP_ID); logger.info("process.env.APPLE_APP_ID = " + process.env.APPLE_APP_ID);
logger.info("process.env.EMAIL_SERVICE = " + process.env.EMAIL_SERVICE); logger.info("process.env.EMAIL_SERVICE = " + process.env.EMAIL_SERVICE);
@ -118,6 +124,12 @@ nextApp
next(); next();
}); });
server.use("/favicon.ico", express.static("public/favicon.png")); server.use("/favicon.ico", express.static("public/favicon.png"));
// serve the same image for pwa-192x192.png and pwa-512x512.png
server.use("/pwa-192x192.png", express.static("public/favicon.png"));
server.use("/pwa-512x512.png", express.static("public/favicon.png"));
server.use("/manifest.json", express.static("public/manifest.json"));
//all static files are served from the public folder, including subfolders
server.use(express.static("public")); //ToDo: not working for some reason
// server.use("/robots.txt", express.static("styles/favicon_io/robots.txt")); // server.use("/robots.txt", express.static("styles/favicon_io/robots.txt"));
// server.use("/sitemap.xml", express.static("styles/favicon_io/sitemap.xml")); // server.use("/sitemap.xml", express.static("styles/favicon_io/sitemap.xml"));

View File

@ -5,9 +5,10 @@ import { applyAuthTokenInterceptor } from 'axios-jwt';
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: common.getBaseUrl(), baseURL: common.getBaseUrl(),
withCredentials: true, withCredentials: true,
// headers: { headers: {
// "Content-Type": "application/json", // "Content-Type": "application/json",
// }, "Content-Encoding": "utf-8"
},
}); });
// 2. Define token refresh function. // 2. Define token refresh function.

View File

@ -31,7 +31,7 @@ const axiosServer = async (context) => {
} }
else { else {
//redirect to next-auth login page //redirect to next-auth login page
context.res.writeHead(302, { Location: '/api/auth/signin' }); context.res.writeHead(302, { Location: encodeURIComponent('/api/auth/signin') });
context.res.end(); context.res.end();
return { props: {} }; return { props: {} };
} }

View File

@ -10,7 +10,7 @@ const { PrismaClient, UserRole } = require('@prisma/client');
const DayOfWeek = require("@prisma/client").DayOfWeek; const DayOfWeek = require("@prisma/client").DayOfWeek;
const winston = require('winston'); const winston = require('winston');
const { getSession } = require("next-auth/react"); const { getSession } = require("next-auth/react");
const { DateTime, FixedOffsetZone } = require('luxon');
const logger = winston.createLogger({ const logger = winston.createLogger({
level: 'info', // Set the default log level level: 'info', // Set the default log level
@ -36,6 +36,27 @@ exports.logger = logger;
// dotenv.config(); // dotenv.config();
// // dotenv.config({ path: ".env.local" }); // // dotenv.config({ path: ".env.local" });
exports.adjustUtcTimeToSofia = function (time) {
// Convert the Date object to a Luxon DateTime object in UTC
let result = DateTime.fromJSDate(time, { zone: 'utc' });
// Convert to Sofia time, retaining the local time as provided
result = result.setZone('Europe/Sofia', { keepLocalTime: true });
// Set hours, minutes, and seconds to match the input time
result = result.set({
hour: time.getHours(),
minute: time.getMinutes(),
second: time.getSeconds()
});
return result.toJSDate();
};
exports.adjustTimeToUTC = function (time) {
let result = DateTime.fromJSDate(time, { zone: 'Europe/Sofia' });
result = result.setZone('utc', { keepLocalTime: true });
return result.toJSDate();
};
exports.isValidPhoneNumber = function (phone) { exports.isValidPhoneNumber = function (phone) {
if (typeof phone !== 'string') { if (typeof phone !== 'string') {
return false; // or handle as you see fit return false; // or handle as you see fit
@ -56,17 +77,6 @@ exports.isValidPhoneNumber = function (phone) {
// If neither condition is met, the phone number is invalid // If neither condition is met, the phone number is invalid
return false; return false;
} }
exports.setBaseUrl = function (req) {
const protocol = req.headers['x-forwarded-proto'] || 'http';
const host = req.headers.host;
const baseUrl = `${protocol}://${host}`;
// Write the baseUrl to the file
if (req != null) {
fs.writeFileSync(path.join(__dirname, 'baseUrl.txt'), baseUrl, 'utf8');
}
return baseUrl;
};
exports.getBaseUrl = function (relative = "", req = null) { exports.getBaseUrl = function (relative = "", req = null) {
return process.env.NEXT_PUBLIC_PUBLIC_URL + relative; return process.env.NEXT_PUBLIC_PUBLIC_URL + relative;
@ -329,14 +339,8 @@ exports.compareTimes = function (time1, time2) {
const time2String = `${getHours(time2)}:${getMinutes(time2)}`; const time2String = `${getHours(time2)}:${getMinutes(time2)}`;
return time1String.localeCompare(time2String); return time1String.localeCompare(time2String);
}; };
exports.normalizeTime = function (date, baseDate) { exports.normalizeTime = function (date, baseDate) {
// return set(baseDate, {
// hours: getHours(date),
// minutes: getMinutes(date),
// seconds: getSeconds(date),
// milliseconds: 0
// });
//don't use date-fns
let newDate = new Date(baseDate); let newDate = new Date(baseDate);
newDate.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), 0); newDate.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), 0);
return newDate; return newDate;
@ -366,10 +370,7 @@ exports.getDateFormatedShort = function (date) {
} }
exports.getTimeFomatted = function (date) {
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Sofia' });//timeZone: 'local'
}
/*Todo: remove: /*Todo: remove:
toISOString toISOString
@ -528,7 +529,7 @@ exports.fuzzySearch = function (publishers, searchQuery, distanceThreshold = 0.9
} }
exports.getCurrentNonthFormatted = function () { exports.getCurrentMonthFormatted = function () {
const getCurrentYearMonth = () => { const getCurrentYearMonth = () => {
const currentDate = new Date(); const currentDate = new Date();
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
@ -544,51 +545,140 @@ exports.getCurrentYearMonth = () => {
const month = String(currentDate.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed const month = String(currentDate.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
return `${year}-${month}`; return `${year}-${month}`;
} }
exports.getTimeFormated = function (date) {
return this.formatTimeHHmm(date);
}
// format date to 'HH:mm' time string required by the time picker
exports.formatTimeHHmm = function (input) {
// Check if the input is a string or a Date object
const date = (typeof input === 'string') ? new Date(input) : input;
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Sofia'
}).substring(0, 5);
}
//parse 'HH:mm' time string to date object // new date FNs
exports.parseTimeHHmm = (timeString) => { /**
// If timeString is already a date, return it as is * Parses an input into a Luxon DateTime object, setting the timezone to 'Europe/Sofia' while keeping the local time.
if (timeString instanceof Date) { * @param {string|Date} input - The input date string or JavaScript Date object.
return timeString; * @returns {DateTime} - A Luxon DateTime object with the timezone set to 'Europe/Sofia', preserving the local time.
} */
const parseDate = (input) => {
let dateTime;
const [hours, minutes] = timeString.split(':'); if (input instanceof DateTime) {
const date = new Date(); // If input is already a Luxon DateTime, we adjust the zone only.
date.setHours(hours); dateTime = input.setZone('Europe/Sofia');
date.setMinutes(minutes); } else if (typeof input === 'string' || input instanceof Date) {
return date; // Create a DateTime from the input assuming local timezone to preserve local time when changing the zone.
} dateTime = DateTime.fromJSDate(new Date(input), { zone: 'local' });
dateTime = dateTime.setZone('Europe/Sofia');
exports.setTimeHHmm = (date, timeStringOrHours) => {
const newDate = new Date(date);
if (typeof timeStringOrHours === 'string' && timeStringOrHours.includes(':')) {
// If hours is a string in "HH:mm" format
const [h, m] = timeStringOrHours.split(':');
newDate.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
} else { } else {
// If hours and minutes are provided separately // Use the current time if no input is given, considered as local time.
newDate.setHours(parseInt(timeStringOrHours, 10), 0, 0, 0); dateTime = DateTime.local().setZone('Europe/Sofia');
} }
return newDate; // Set the timezone to 'Europe/Sofia' while keeping the original local time.
return dateTime.setZone('Europe/Sofia', { keepLocalTime: true });
}; };
exports.parseDate = parseDate;
// Set timezone to 'Europe/Sofia' without translating time
exports.setTimezone = (input) => {
let dateTime = parseDate(input);
dateTime = dateTime.setZone('Europe/Sofia', { keepLocalTime: true });
return dateTime.toJSDate();
};
exports.setTime = (baseDateTime, timeDateTime) => {
// Ensure both inputs are DateTime objects
baseDateTime = parseDate(baseDateTime);
timeDateTime = parseDate(timeDateTime);
return baseDateTime.set({
hour: timeDateTime.hour,
minute: timeDateTime.minute,
second: timeDateTime.second,
millisecond: timeDateTime.millisecond
});
};
// Format date to a specified format, defaulting to 'HH:mm'
exports.getTimeFormatted = (input, format = 'HH:mm') => {
const dateTime = parseDate(input);
return dateTime.toFormat(format);
};
// Set time in 'HH:mm' format to a date and return as JS Date in Sofia timezone
exports.setTimeHHmm = (input, timeString) => {
let dateTime = parseDate(input);
const [hour, minute] = timeString.split(':').map(Number);
dateTime = dateTime.set({ hour, minute });
return dateTime.toJSDate();
};
// Parse 'HH:mm' time string to a JS Date object in Sofia timezone for today
exports.parseTimeHHmm = (timeString) => {
const dateTime = DateTime.now({ zone: 'Europe/Sofia' });
const [hour, minute] = timeString.split(':').map(Number);
return dateTime.set({ hour, minute }).toJSDate();
};
function isTimeBetween(startTime, endTime, checkTime) {
const start = new Date(0, 0, 0, startTime.getHours(), startTime.getMinutes());
const end = new Date(0, 0, 0, endTime.getHours(), endTime.getMinutes());
const check = new Date(0, 0, 0, checkTime.getHours(), checkTime.getMinutes());
// If the end time is less than the start time, it means the time range spans midnight
if (end < start) {
// Check time is between start and midnight or between midnight and end
return (check >= start && check <= new Date(0, 0, 1, 0, 0, 0)) || (check >= new Date(0, 0, 0, 0, 0, 0) && check <= end);
} else {
return check >= start && check <= end;
}
}
exports.isTimeBetween = isTimeBetween;
// ToDo: update all uses of this function to use the new one
// exports.getTimeFormatted = function (date) {
// const dateTime = DateTime.fromJSDate(date, { zone: 'Europe/Sofia' });
// return dateTime.toFormat('HH:mm');
// };
// exports.setTimeHHmm = (date, timeStringOrHours) => {
// const newDate = new Date(date);
// if (typeof timeStringOrHours === 'string' && timeStringOrHours.includes(':')) {
// // If hours is a string in "HH:mm" format
// const [h, m] = timeStringOrHours.split(':');
// newDate.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
// } else {
// // If hours and minutes are provided separately
// newDate.setHours(parseInt(timeStringOrHours, 10), 0, 0, 0);
// }
// return newDate;
// };
// // format date to 'HH:mm' time string required by the time picker
// exports.formatTimeHHmm = function (input) {
// // Check if the input is a string or a Date object
// const date = (typeof input === 'string') ? new Date(input) : input;
// return date.toLocaleTimeString('en-US', {
// hour12: false,
// hour: '2-digit',
// minute: '2-digit',
// timeZone: 'Europe/Sofia'
// }).substring(0, 5);
// }
// //parse 'HH:mm' time string to date object
// exports.parseTimeHHmm = (timeString) => {
// // If timeString is already a date, return it as is
// if (timeString instanceof Date) {
// return timeString;
// }
// const [hours, minutes] = timeString.split(':');
// const date = new Date();
// date.setHours(hours);
// date.setMinutes(minutes);
// return date;
// }
exports.getTimeInMinutes = (dateOrTimestamp) => { exports.getTimeInMinutes = (dateOrTimestamp) => {
const date = new Date(dateOrTimestamp); const date = new Date(dateOrTimestamp);
@ -775,8 +865,13 @@ exports.convertDatesToISOStrings = function (obj) {
return obj; return obj;
} }
// if (obj instanceof Date) {
// return obj.toISOString();
// }
if (obj instanceof Date) { if (obj instanceof Date) {
return obj.toISOString(); // Convert the Date object to a Luxon DateTime in UTC
const utcDate = DateTime.fromJSDate(obj, { zone: 'utc' });
return utcDate.toISO(); // Output in UTC as ISO string
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
@ -793,8 +888,43 @@ exports.convertDatesToISOStrings = function (obj) {
return obj; return obj;
} }
function adjustDateForDST(date, timezone) {
// Convert the date to the specified timezone
let dateTime = DateTime.fromJSDate(date, { zone: timezone });
// Check if the original date is in DST
const isOriginalDST = dateTime.isInDST;
// Check if the current date in the same timezone is in DST
const isNowDST = DateTime.now().setZone(timezone).isInDST;
// Compare and adjust if necessary
if (isOriginalDST && !isNowDST) {
// If original date was in DST but now is not, subtract one hour
dateTime = dateTime.minus({ hours: 1 });
} else if (!isOriginalDST && isNowDST) {
// If original date was not in DST but now is, add one hour
dateTime = dateTime.plus({ hours: 1 });
}
// Return the adjusted date as a JavaScript Date
return dateTime.toJSDate();
}
exports.adjustDateForDST = adjustDateForDST;
exports.base64ToUint8Array = function (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
const buffer = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
buffer[i] = rawData.charCodeAt(i);
}
return buffer;
}
// exports.getInitials = function (names) { // exports.getInitials = function (names) {
// const parts = names.split(' '); // Split the full name into parts // const parts = names.split(' '); // Split the full name into parts
// if (parts.length === 0) { // if (parts.length === 0) {

View File

@ -158,7 +158,7 @@ async function getAvailabilities(userId) {
...item, ...item,
startTime: item.startTime.toISOString(), startTime: item.startTime.toISOString(),
endTime: item.endTime.toISOString(), endTime: item.endTime.toISOString(),
name: common.getTimeFomatted(item.startTime) + "-" + common.getTimeFomatted(item.endTime), name: common.getTimeFormatted(item.startTime) + "-" + common.getTimeFormatted(item.endTime),
//endDate can be null //endDate can be null
endDate: item.endDate ? item.endDate.toISOString() : null, endDate: item.endDate ? item.endDate.toISOString() : null,
type: 'availability', type: 'availability',
@ -214,7 +214,7 @@ async function getAvailabilities(userId) {
endTime: item.shift.endTime.toISOString(), endTime: item.shift.endTime.toISOString(),
// name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "), // name: item.shift.publishers.map(p => p.firstName + " " + p.lastName).join(", "),
//name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "), //name: item.shift.assignments.map(a => a.publisher.firstName[0] + " " + a.publisher.lastName).join(", "),
name: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)), name: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
type: 'assignment', type: 'assignment',
//delete shift object //delete shift object
shift: null, shift: null,
@ -614,7 +614,7 @@ function convertShiftDates(assignments) {
} }
async function getCalendarEvents(publisherId, date, availabilities = true, assignments = true) { async function getCalendarEvents(publisherId, availabilities = true, assignments = true, includeUnpublished = false) {
const result = []; const result = [];
// let pubs = await filterPublishers("id,firstName,lastName,email".split(","), "", date, assignments, availabilities, date ? true : false, publisherId); // let pubs = await filterPublishers("id,firstName,lastName,email".split(","), "", date, assignments, availabilities, date ? true : false, publisherId);
@ -647,6 +647,7 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
assignments: { assignments: {
select: { select: {
id: true, id: true,
// publisherId: true,
shift: { shift: {
select: { select: {
id: true, id: true,
@ -665,7 +666,7 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
publisher.availabilities?.forEach(item => { publisher.availabilities?.forEach(item => {
result.push({ result.push({
...item, ...item,
title: common.getTimeFomatted(new Date(item.startTime)) + "-" + common.getTimeFomatted(new Date(item.endTime)), //item.name, title: common.getTimeFormatted(new Date(item.startTime)) + "-" + common.getTimeFormatted(new Date(item.endTime)), //item.name,
date: new Date(item.startTime), date: new Date(item.startTime),
startTime: new Date(item.startTime), startTime: new Date(item.startTime),
endTime: new Date(item.endTime), endTime: new Date(item.endTime),
@ -681,23 +682,21 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
//only published shifts //only published shifts
publisher.assignments?.filter( publisher.assignments?.filter(
assignment => assignment.shift.isPublished assignment => assignment.shift.isPublished || includeUnpublished
).forEach(item => { ).forEach(item => {
result.push({ result.push({
...item, ...item,
title: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)), title: common.getTimeFormatted(new Date(item.shift.startTime)) + "-" + common.getTimeFormatted(new Date(item.shift.endTime)),
date: new Date(item.shift.startTime), date: new Date(item.shift.startTime),
startTime: new Date(item.shift.startTime), startTime: new Date(item.shift.startTime),
endTime: new Date(item.shift.endTime), endTime: new Date(item.shift.endTime),
publisherId: item.publisherid, // publisherId: item.publisherId,
publisherId: publisher.id,
type: "assignment", type: "assignment",
}); });
}); });
} }
} }
return result; return result;
} }

37
workbox-config.js Normal file
View File

@ -0,0 +1,37 @@
// importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');
// // ToDo: probably not used now as we use next-pwa( check config)
// // Only import the modules you need; skip precaching and routing if not needed
// workbox.core.skipWaiting();
// workbox.core.clientsClaim();
// //workbox.precaching.cleanupOutdatedCaches();
// //disable precaching
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// module.exports = {
// // Other webpack config...
// plugins: [
// // Other plugins...
// new InjectManifest({
// // These are some common options, and not all are required.
// // Consult the docs for more info.
// exclude: [/.../, '...'],
// maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// }),
// ],
// };
// // Example: Set up push notification handling
// self.addEventListener('push', event => {
// console.log('Push event received at workbox.config: ', event);
// const data = event.data.json();
// event.waitUntil(
// self.registration.showNotification(data.title, {
// body: data.message,
// icon: '/path/to/icon.png'
// })
// );
// });

71
worker.js Normal file
View File

@ -0,0 +1,71 @@
// 'use strict'
// // currently not used as we ise next-pwa and in next.config.js we have withPWA.
// // maybe we can have withPWA({sw: './worker.js'}) ?
// console.log('Service Worker worker/index.js Loaded...')
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// self.addEventListener('install', () => {
// console.log('service worker installed')
// });
// self.addEventListener('activate', () => {
// console.log('service worker activated')
// });
// self.addEventListener('fetch', (event) => {
// try {
// console.log('Fetch event for ', event.request.url);
// if (event.request.url.includes('/api/auth/callback/')) {
// // Use network only strategy for auth routes, or bypass SW completely
// event.respondWith(fetch(event.request));
// return;
// }
// // other caching strategies...
// } catch (error) {
// console.error(error)
// }
// });
// self.addEventListener('push', function (event) {
// console.log('Push message', event)
// if (!(self.Notification && self.Notification.permission === 'granted')) {
// return
// }
// const data = JSON.parse(event.data.text())
// event.waitUntil(
// registration.showNotification(data.title, {
// body: data.message,
// icon: '/icons/android-chrome-192x192.png'
// })
// )
// })
// self.addEventListener('notificationclick', function (event) {
// console.log('Notification click: tag', event.notification.tag)
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
// if (clientList.length > 0) {
// let client = clientList[0]
// for (let i = 0; i < clientList.length; i++) {
// if (clientList[i].focused) {
// client = clientList[i]
// }
// }
// return client.focus()
// }
// return clients.openWindow('/')
// })
// )
// })
// // self.addEventListener('pushsubscriptionchange', function(event) {
// // event.waitUntil(
// // Promise.all([
// // Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true),
// // Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration))
// // .then(function(sub) { return saveSubscription(sub) })
// // ])
// // )
// // })

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
console.log('Service Worker Loaded...') console.log('SW /worker/index.js Loaded...')
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
try { try {
@ -16,22 +16,44 @@ self.addEventListener('fetch', (event) => {
}); });
self.addEventListener('push', function (event) { self.addEventListener('push', function (event) {
console.log('Push message', event) console.log('SW: New push message', event)
if (!(self.Notification && self.Notification.permission === 'granted')) { if (!(self.Notification && self.Notification.permission === 'granted')) {
return return
} }
const data = JSON.parse(event.data.text()) const data = JSON.parse(event.data.text())
console.log('SW: Push data', data)
actions: [
//font awesome icons
{ action: 'accept', title: 'Accept', icon: 'fa fa-check' },
{ action: 'decline', title: 'Decline', icon: 'fa fa-times' }
]
event.waitUntil( event.waitUntil(
registration.showNotification(data.title, { registration.showNotification(data.title, {
body: data.message, body: data.message,
icon: '/icons/android-chrome-192x192.png' icon: '/favicon.ico',
actions: [{ action: 'close', title: 'Close', icon: 'fa fa-times' }],
data: data.url,
}) })
) )
}) })
self.addEventListener('notificationclick', function (event) { self.addEventListener('notificationclick', function (event) {
console.log('Notification click: tag', event.notification.tag) console.log('Notification click: tag', event.notification.tag, 'action', event.action)
event.notification.close() event.notification.close()
switch (event.action) {
case 'accept':
console.log('User accepted the action.');
// handle acceptance
break;
case 'decline':
console.log('User declined the action.');
// handle decline
break;
default:
// handle other cases
break;
}
console.log(event)
event.waitUntil( event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
if (clientList.length > 0) { if (clientList.length > 0) {