initial commit - code moved to separate repo

This commit is contained in:
Dobromir Popov
2024-02-22 04:19:38 +02:00
commit 560d503219
240 changed files with 105125 additions and 0 deletions

26
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:18-slim
# Set the working directory in the container
WORKDIR /workspace
# Copy package.json and package-lock.json to the container
# skip if we use bind
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code to the container
# skip if we use bind
COPY . .
# Expose the default Next.js port
EXPOSE 5011
# Start the Next.js development server
CMD ["npm", "run", "run"]
# CMD ["npm", "run", "debug"]
# RUN npm run build
# the -- in the command is used to pass the following arguments directly to the script (or command) that npm start runs.
# CMD ["npm", "start", "--", "-p", "3010"]

View File

@ -0,0 +1,39 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
{
"name": "Existing Docker Compose (Extend)",
// Update the 'dockerComposeFile' list if you have more compose files or use different names.
// The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
"dockerComposeFile": [
"../docker-compose.yml"
// "docker-compose.yml"
],
// The 'service' property is the name of the service for the container that VS Code should
// use. Update this value and .devcontainer/docker-compose.yml to the real service name.
"service": "next-cart-app",
// The optional 'workspaceFolder' property is the path VS Code should open by default when
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
// "workspaceFolder": "/workspace/${localWorkspaceFolderBasename}",
"workspaceFolder": "/workspace",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {
// "ghcr.io/devcontainers-contrib/features/prisma:2": {}
// },
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [
// 3010
// ],
// Uncomment the next line if you want start specific services in your Docker Compose config.
// "runServices": [],
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
// "shutdownAction": "none",
// Uncomment the next line to run commands after the container is created.
// "postCreateCommand": "cat /etc/os-release",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "devcontainer"
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
]
}

49
.env Normal file
View File

@ -0,0 +1,49 @@
#NODE_TLS_REJECT_UNAUTHORIZED='0'
NEXT_PUBLIC_PROTOCOL=https
NEXT_PUBLIC_HOST=localhost
NEXT_PUBLIC_PORT=3003
NEXTAUTH_URL=https://localhost:3003
# NEXTAUTH_URL_INTERNAL=http://127.0.0.1:3003
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
# mysql
DATABASE_PROVIDER=mysql
# DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart_dev
DATABASE_URL=mysql://root:Zelen0ku4e@192.168.0.10:3306/cart_dev
# DATABASE_URL=mysql://cart:cartpw@20.101.62.76:3307/cart
# sqlite
# DATABASE_PROVIDER=sqlite
# DATABASE_URL=file:./prisma/local.db
APPLE_ID=
APPLE_TEAM_ID=
APPLE_PRIVATE_KEY=
APPLE_KEY_ID=
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
FACEBOOK_ID=
FACEBOOK_SECRE
GITHUB_ID=
GITHUB_SECRET=
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
GOOGLE_SECRET=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
TWITTER_ID=
TWITTER_SECRET=
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
EMAIL_FROM=noreply@example.com
GMAIL_EMAIL_USERNAME=
GMAIL_EMAIL_APP_PASS=

37
.env.demo Normal file
View File

@ -0,0 +1,37 @@
NODE_TLS_REJECT_UNAUTHORIZED='0'
# DATABASE_URL="file:./src/data/dev.db"
# DATABASE_URL="mysql://root:Zelen0ku4e@192.168.0.10:3306/cart"
NEXT_PUBLIC_PORT=
# NEXT_PUBLIC_NEXTAUTH_URL=https://cart.d-popov.com
NEXT_PUBLIC_PROTOCOL=https
NEXT_PUBLIC_HOST=cart.d-popov.com
NEXTAUTH_URL=https://cart.d-popov.com
# NEXTAUTH_URL= https://demo.mwhitnessing.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart_demo
APPLE_ID=
APPLE_TEAM_ID=
APPLE_PRIVATE_KEY=
APPLE_KEY_ID=
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
FACEBOOK_ID=
FACEBOOK_SECRET=
GITHUB_ID=
GITHUB_SECRET=
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
TWITTER_ID=
TWITTER_SECRET=
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
EMAIL_FROM=noreply@example.com

9
.env.prod Normal file
View File

@ -0,0 +1,9 @@
NEXT_PUBLIC_PROTOCOL=https
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_HOST=sofia.mwhitnessing.com
NEXTAUTH_URL= https://sofia.mwhitnessing.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_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia

9
.env.production Normal file
View File

@ -0,0 +1,9 @@
NEXT_PUBLIC_PROTOCOL=https
NEXT_PUBLIC_PORT=
NEXT_PUBLIC_HOST=sofia.mwhitnessing.com
NEXTAUTH_URL= https://sofia.mwhitnessing.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_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia

36
.env.test Normal file
View File

@ -0,0 +1,36 @@
NODE_TLS_REJECT_UNAUTHORIZED='0'
NEXT_PUBLIC_PORT=5001
NEXT_PUBLIC_PROTOCOL=https
NEXT_PUBLIC_HOST=cart.d-popov.com
NEXTAUTH_URL=https://cart.d-popov.com
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
DATABASE_URL=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
APPLE_ID=
APPLE_TEAM_ID=
APPLE_PRIVATE_KEY=
APPLE_KEY_ID=
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
FACEBOOK_ID=
FACEBOOK_SECRET=
GITHUB_ID=
GITHUB_SECRET=
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
GOOGLE_SECRET=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
TWITTER_ID=
TWITTER_SECRET=
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
EMAIL_FROM=noreply@example.com
GMAIL_EMAIL_USERNAME=
GMAIL_EMAIL_APP_PASS=

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
.DS_Store
node_modules/
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.yarn-integrity
.npm
.eslintcache
*.tsbuildinfo
next-env.d.ts
.next
.vercel
.env*.local
*.zip
next-cart-app.zip
!public/uploads/thumb/
certificates
content/output/*

8
.hintrc Normal file
View File

@ -0,0 +1,8 @@
{
"extends": [
"development"
],
"hints": {
"no-inline-styles": "off"
}
}

13
.vs/VSWorkspaceState.json Normal file
View File

@ -0,0 +1,13 @@
{
"ExpandedNodes": [
"",
"\\components",
"\\components\\cartevent",
"\\pages",
"\\pages\\cart",
"\\pages\\cart\\cartevents",
"\\pages\\cart\\publishers",
"\\prisma"
],
"PreviewInSolutionExplorer": false
}

File diff suppressed because it is too large Load Diff

BIN
.vs/next-cart-app/v17/.wsuo Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
.vs/slnx.sqlite Normal file

Binary file not shown.

10
.vs/tasks.vs.json Normal file
View File

@ -0,0 +1,10 @@
{
"version": "0.2.1",
"tasks": [
{
"taskLabel": "task-server",
"appliesTo": "server.js",
"type": "launch"
}
]
}

75
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,75 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Chrome Debug",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3003",
"webRoot": "${workspaceFolder}/src",
"runtimeExecutable": "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
"sourceMapPathOverrides": {
"webpack:///src/*": "${webRoot}/*"
}
},
{
"name": "Run npm nodemon",
"command": "npm run debug ", // > _logs/debug.log
"request": "launch",
"type": "node-terminal",
"preLaunchTask": "killInspector", // <-- Add this line
},
{
// "type": "pwa-node",
// "request": "launch",
// "name": "Next: Node",
// "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next",
"name": "next dev",
"command": "npm run devNext",
"request": "launch",
"type": "node-terminal"
},
{
// "type": "pwa-node",
// "request": "launch",
// "name": "Next: Node",
// "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next",
"name": "Run npm next start",
"command": "npm run start",
"request": "launch",
"type": "node-terminal"
},
{
"name": "Run conda npm debug",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}",
"command": "conda activate node && npm run debug",
},
{
"name": "Run conda npm TEST",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}",
"command": "conda activate node && npm run test",
"env": {
"NODE_ENV": "test"
}
},
{
"type": "node",
"request": "attach",
"name": "Attach nodemon",
"processId": "${command:PickProcess}",
"restart": true,
// "protocol": "inspector",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**"
]
}
]
}

124
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,124 @@
{
"diffEditor.codeLens": true,
"editor.tabCompletion": "on",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"html.format.wrapAttributes": "force",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
// "source.organizeImports": true
},
"[dotenv]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"[ignore]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"sqltools.connections": [
{
"mysqlOptions": {
"authProtocol": "default"
},
"previewLimit": 50,
"server": "192.168.0.10",
"port": 3306,
"driver": "MariaDB",
"name": "docker",
"database": "mysql",
"username": "root"
},
{
"mssqlOptions": {
"appName": "SQLTools",
"useUTC": true,
"encrypt": true
},
"previewLimit": 50,
"server": "192.168.0.10",
"port": 1433,
"askForPassword": true,
"driver": "MSSQL",
"name": "mssql.d-popov.com",
"database": "master",
"username": "sa"
},
{
"mysqlOptions": {
"authProtocol": "default"
},
"previewLimit": 50,
"server": "172.160.240.73",
"port": 3306,
"driver": "MariaDB",
"name": "Azure cart jwpw",
"database": "jwpwsofia",
"username": "jwpwsofia"
},
{
"mysqlOptions": {
"authProtocol": "default",
"enableSsl": "Disabled"
},
"previewLimit": 50,
"server": "localhost",
"port": 3306,
"driver": "MariaDB",
"name": "local dev cart",
"database": "cart",
"username": "root",
"password": "7isg3FCqP1e9aSFw"
},
{
"mysqlOptions": {
"authProtocol": "default"
},
"previewLimit": 50,
"server": "jwpw.mysql.database.azure.com",
"port": 3306,
"driver": "MariaDB",
"name": "mysql on Azure",
"database": "mysql",
"username": "popov",
"password": "F7%WZE%@1G&Bjm"
}
],
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"vsmqtt.brokerProfiles": [
{
"name": "Home",
"host": "192.168.0.10",
"port": 1883,
"clientId": "vsmqtt_client"
}
],
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"git-blame.gitWebUrl": "",
"[csharp]": {
"editor.defaultFormatter": "ms-dotnettools.csharp"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"[sql]": {
"editor.defaultFormatter": "cweijan.vscode-mysql-client2"
},
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
},
"[javascriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

129
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,129 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Install Dependencies conda test",
"type": "shell",
"command": "source activate node && export NODE_ENV=test && npm install",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Run Server conda test",
"type": "shell",
"command": "source activate node && export NODE_ENV=test && node server.js",
"dependsOn": [
"Install Dependencies"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Run Server conda test",
"type": "shell",
"command": "source activate node && export NODE_ENV=test && node server.js",
"dependsOn": [
"Install Dependencies"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "killInspector",
"type": "shell",
"command": "FOR /F \"tokens=5\" %p IN ('netstat -ano ^| find \"9229\" ^| find \"LISTENING\"') DO taskkill /PID %p",
"problemMatcher": [],
"windows": {
"command": "FOR /F \"tokens=5\" %p IN ('netstat -ano ^| find \"9229\" ^| find \"LISTENING\"') DO taskkill /F /PID %p",
"options": {
"shell": {
"executable": "cmd.exe",
"args": [
"/d",
"/c"
]
}
}
},
"linux": {
"command": "lsof -t -i:9229 | xargs -r kill -9"
},
},
{
"label": "Remove node_modules",
"type": "shell",
"command": "Remove-Item -Recurse -Force node_modules",
"problemMatcher": [],
"windows": {
"command": "Remove-Item -Recurse -Force node_modules",
"options": {
"shell": {
"executable": "powershell.exe",
"args": [
"-Command"
]
}
}
},
"linux": {
"command": "rm -rf node_modules"
}
},
{
"label": "Build Docker Image",
"type": "shell",
"command": "docker build -t next-cart-app:dev .",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "Docker Compose Up",
"type": "shell",
"command": "docker-compose up --build",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "Deploy to Remote Server",
"type": "shell",
"command": "rsync",
"args": [
"-avz",
".next/",
"public/",
"package.json",
"package-lock.json",
"server.js",
"${env:REMOTE_USER}@${env:REMOTE_SERVER}:${env:REMOTE_DESTINATION}"
],
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"env": {
"REMOTE_USER": "azureuser", // Replace with your remote username
"REMOTE_SERVER": "172.160.240.73", // Replace with your remote server IP or hostname
"REMOTE_DESTINATION": "/mnt/docker_volumes/pw/app/" // Replace with your destination path on the remote server
}
}
}
]
}

113
README.md Normal file
View File

@ -0,0 +1,113 @@
> The example repository is maintained from a [monorepo](https://github.com/nextauthjs/next-auth/tree/main/apps/example-nextjs). Pull Requests should be opened against [`nextauthjs/next-auth`](https://github.com/nextauthjs/next-auth).
<p align="center">
<br/>
<a href="https://next-auth.js.org" target="_blank"><img width="150px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a>
<h3 align="center">NextAuth.js Example App</h3>
<p align="center">
Open Source. Full Stack. Own Your Data.
</p>
<p align="center" style="align: center;">
<a href="https://npm.im/next-auth">
<img alt="npm" src="https://img.shields.io/npm/v/next-auth?color=green&label=next-auth">
</a>
<a href="https://bundlephobia.com/result?p=next-auth-example">
<img src="https://img.shields.io/bundlephobia/minzip/next-auth?label=next-auth" alt="Bundle Size"/>
</a>
<a href="https://www.npmtrends.com/next-auth">
<img src="https://img.shields.io/npm/dm/next-auth?label=next-auth%20downloads" alt="Downloads" />
</a>
<a href="https://npm.im/next-auth">
<img src="https://img.shields.io/badge/npm-TypeScript-blue" alt="TypeScript" />
</a>
</p>
</p>
## Overview
NextAuth.js is a complete open source authentication solution.
This is an example application that shows how `next-auth` is applied to a basic Next.js app.
The deployed version can be found at [`next-auth-example.vercel.app`](https://next-auth-example.vercel.app)
### About NextAuth.js
NextAuth.js is an easy to implement, full-stack (client/server) open source authentication library originally designed for [Next.js](https://nextjs.org) and [Serverless](https://vercel.com). Our goal is to [support even more frameworks](https://github.com/nextauthjs/next-auth/issues/2294) in the future.
Go to [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
> *NextAuth.js is not officially associated with Vercel or Next.js.*
## Getting Started
### 1. Clone the repository and install dependencies
```
git clone https://github.com/nextauthjs/next-auth-example.git
cd next-auth-example
npm install
```
### 2. Configure your local environment
Copy the .env.local.example file in this directory to .env.local (which will be ignored by Git):
```
cp .env.local.example .env.local
```
Add details for one or more providers (e.g. Google, Twitter, GitHub, Email, etc).
#### Database
A database is needed to persist user accounts and to support email sign in. However, you can still use NextAuth.js for authentication without a database by using OAuth for authentication. If you do not specify a database, [JSON Web Tokens](https://jwt.io/introduction) will be enabled by default.
You **can** skip configuring a database and come back to it later if you want.
For more information about setting up a database, please check out the following links:
* Docs: [next-auth.js.org/adapters/overview](https://next-auth.js.org/adapters/overview)
### 3. Configure Authentication Providers
1. Review and update options in `pages/api/auth/[...nextauth].js` as needed.
2. When setting up OAuth, in the developer admin page for each of your OAuth services, you should configure the callback URL to use a callback path of `{server}/api/auth/callback/{provider}`.
e.g. For Google OAuth you would use: `http://localhost:3000/api/auth/callback/google`
A list of configured providers and their callback URLs is available from the endpoint `/api/auth/providers`. You can find more information at https://next-auth.js.org/configuration/providers/oauth
3. You can also choose to specify an SMTP server for passwordless sign in via email.
### 4. Start the application
To run your site locally, use:
```
npm run dev
```
To run it in production mode, use:
```
npm run build
npm run start
```
### 5. Preparing for Production
Follow the [Deployment documentation](https://next-auth.js.org/deployment)
## Acknowledgements
<a href="https://vercel.com?utm_source=nextauthjs&utm_campaign=oss">
<img width="170px" src="https://raw.githubusercontent.com/nextauthjs/next-auth/canary/www/static/img/powered-by-vercel.svg" alt="Powered By Vercel" />
</a>
<p align="left">Thanks to Vercel sponsoring this project by allowing it to be deployed for free for the entire NextAuth.js Team</p>
## License
ISC

19
_deploy/demo.10.yml Normal file
View File

@ -0,0 +1,19 @@
version: "3.1"
services:
nextjs-app:
image: node:20.11.0-alpine #sachinvashist/nextjs-docker #
# jitesoft/node-base
# paketobuildpacks/nodejs
ports:
- "8005:3000"
volumes:
- /mnt/apps/docker_volumes/cart/app/next-cart-app:/app
environment:
- NODE_ENV=demo
- DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart
#command: sh -c "apk update && apk add git && rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone && rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/ && rm -rf /tmp/clone && npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
#command: sh -c "rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone && rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/ && rm -rf /tmp/clone && npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
command: sh -c "npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
#command: npm run test; tail -f /dev/null
tty: true
stdin_open: true

14
_deploy/demo.11-demo.yml Normal file
View File

@ -0,0 +1,14 @@
version: "3.1"
services:
nextjs-app:
image: node:20.11.0-alpine #sachinvashist/nextjs-docker #
ports:
- "5001:3000" # Map port 3000 from container to host
volumes:
- /mnt/apps/DEV/cart-demo:/app
environment:
- NODE_ENV=demo
- DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart
command: sh -c " cd /app && npm run test; tail -f /dev/null"
tty: true
stdin_open: true

View File

@ -0,0 +1,112 @@
version: "3"
services:
nextjs-app: # https://sofia.mwhitnessing.com/
hostname: jwpw-app # jwpw-nextjs-app-1
image: docker.d-popov.com/jwpw:latest
#ports:
# - "3000:3000"
volumes:
- /mnt/docker_volumes/pw/app/public/content/uploads/:/app/public/content/uploads
environment:
- NODE_ENV=prod
- TZ=Europe/Sofia
#- DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
#- DATABASE_URL=postgres://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
- UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
- GIT_BRANCH=mariadb
- GIT_USERNAME=deploy
- GIT_PASSWORD=%L3Kr2R438u4F7^%40
command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
#command: sh -c " cd /app && n
tty: true
stdin_open: true
restart: always
networks:
- infrastructure_default
mariadb:
hostname: mariadb
image: bitnami/mariadb:latest #mariadb:10.4
volumes:
- /mnt/docker_volumes/pw/data/mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: jwpwsofia
MYSQL_USER: jwpwsofia
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
#MARIADB_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
MYSQL_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
command:
[
"mysqld",
"--max-connections=1000",
"--sql-mode=ALLOW_INVALID_DATES,ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,HIGH_NOT_PRECEDENCE,IGNORE_SPACE,NO_AUTO_CREATE_USER,NO_AUTO_VALUE_ON_ZERO,NO_BACKSLASH_ESCAPES,NO_DIR_IN_CREATE,NO_ENGINE_SUBSTITUTION,NO_FIELD_OPTIONS,NO_KEY_OPTIONS,NO_TABLE_OPTIONS,NO_UNSIGNED_SUBTRACTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES,STRICT_TRANS_TABLES,ANSI,DB2,MAXDB,MSSQL,MYSQL323,MYSQL40,ORACLE,POSTGRESQL,TRADITIONAL",
"--wait-timeout=28800",
]
networks:
- infrastructure_default
postgres:
hostname: postgres
image: postgres
restart: always
# set shared memory limit when using docker-compose
shm_size: 128mb
# or set shared memory limit when deploy via swarm stack
#volumes:
# - type: tmpfs
# target: /dev/shm
# tmpfs:
# size: 134217728 # 128*2^20 bytes = 128Mb
environment:
POSTGRES_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
networks:
- infrastructure_default
# nextjs-app-staging: # https://sofia.mwhitnessing.com/
# image: docker.d-popov.com/jwpw:latest
# ports:
# - "3000:3000"
# volumes:
# - /mnt/docker_volumes/pw/app/.env.sofia:/app/.env.prod
# environment:
# - NODE_ENV=demo
# - TZ=Europe/Sofia
# - DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
# - UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
# - GIT_USERNAME=deploy
# - GIT_PASSWORD=%L3Kr2R438u4F7^%40
# - NEXT_PUBLIC_HOST=demo.mwhitnessing.com
# - NEXTAUTH_URL= https://demo.mwhitnessing.com
# command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
# tty: true
# stdin_open: true
# restart: always
# networks:
# - infrastructure_default
mariadb_backup:
image: alpine:latest
volumes:
- /mnt/docker_volumes/pw/data/backup:/backup
# - ./gdrive_service_account.json:/root/.gdrive_service_account.json
environment:
MYSQL_USER: jwpwsofia
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
MYSQL_DATABASE: jwpwsofia
MYSQL_HOST: mariadb
# GOOGLE_DRIVE_FOLDER_ID: your_google_drive_folder_id
entrypoint: /bin/sh -c
networks:
- infrastructure_default
command: |
"apk add --no-cache mysql-client curl && \
echo '* 2 * * * mysqldump -h $$MYSQL_HOST -P 3306 -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql' > /etc/crontabs/root && \
crond -f -d 8"
# wget -q https://github.com/prasmussen/gdrive/releases/download/2.1.0/gdrive-linux-x64 -O /usr/bin/gdrive && \
# chmod +x /usr/bin/gdrive && \
# gdrive about --service-account /root/.gdrive_service_account.json && \
# echo '0 * * * * /usr/bin/mysqldump -h $$MYSQL_HOST -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE | gzip > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz && gdrive upload --parent $$GOOGLE_DRIVE_FOLDER_ID --service-account /root/.gdrive_service_account.json /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz' > /etc/crontabs/root && crond -f -d 8"
networks:
infrastructure_default:
external: true

39
_deploy/deoloy.azure.yml Normal file
View File

@ -0,0 +1,39 @@
version: "3"
services:
nextjs-app:
image: node:20.11.0-alpine
ports:
- "3000:3000"
working_dir: /app
volumes:
- /mnt/docker_volumes/pw/app:/app
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
command: /bin/sh -c "npm install && npm install -g dotenv-cli next && npx prisma generate && next dev; tail -f /dev/null" # Install dependencies and start the app
#command: sh -c " cd /app && npm run prod; tail -f /dev/null"
#HOST: fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# apk add git && rm -rf /tmp/clone && git clone --depth 1 https://git.d-popov.com/popov/next-cart-app.git /tmp/clone
# cp -R /tmp/clone/next-cart-app/* /app/
# rm -rf /tmp/clone
# npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json
# npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null
tty: true
stdin_open: true
mariadb:
image: mariadb:10.6
environment:
MYSQL_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
MYSQL_DATABASE: jwpwsofia
MYSQL_USER: jwpwsofia
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
ports:
- "3306:3306"
volumes:
- /mnt/docker_volumes/pw/data/mysql:/var/lib/mysql
volumes:
nextjs-app-data:
mysql-data:

View File

@ -0,0 +1,27 @@
version: "3.1"
services:
nextjs-app:
image: docker.d-popov.com/jwpw:latest
ports:
- "5001:3000"
environment:
- NODE_ENV=prod
- DATABASE_URL=mysql://cart:o74x642Rc8@mariadb:3306/cart
- UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
- GIT_USERNAME=deploy
- GIT_PASSWORD=%L3Kr2R438u4F7^%40
command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
tty: true
stdin_open: true
mariadb:
hostname: mariadb
image: mariadb #bitnami/mariadb:latest #mariadb:10.4
environment:
MARIADB_ROOT_PASSWORD: Pw62L$3332JH
MYSQL_ROOT_PASSWORD: Pw62L$3332JH
MYSQL_DATABASE: cart
MYSQL_USER: cart
MYSQL_PASSWORD: o74x642Rc8
networks:
- default
- mysql_default

View File

@ -0,0 +1,34 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md

View File

@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/
ARG NODE_VERSION=18.17.1
################################################################################
# Use node image for base image for all stages.
FROM node:${NODE_VERSION}-alpine as base
# Set working directory for all build stages.
WORKDIR /usr/src/app
################################################################################
# Create a stage for installing production dependecies.
FROM base as deps
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
# Leverage bind mounts to package.json and package-lock.json to avoid having to copy them
# into this layer.
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci --omit=dev
################################################################################
# Create a stage for building the application.
FROM deps as build
# Download additional development dependencies before building, as some projects require
# "devDependencies" to be installed to build. If you don't need this, remove this step.
RUN --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=cache,target=/root/.npm \
npm ci
# Copy the rest of the source files into the image.
COPY . .
# Run the build script.
RUN npm run build
################################################################################
# Create a new stage to run the application with minimal runtime dependencies
# where the necessary files are copied from the build stage.
FROM base as final
# Use production node environment by default.
ENV NODE_ENV production
# Run the application as a non-root user.
USER node
# Copy package.json so that package manager commands can be used.
COPY package.json .
# Copy the production dependencies from the deps stage and also
# the built application from the build stage into the image.
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/deploy ./deploy
# Expose the port that the application listens on.
EXPOSE 3000
# Run the application.
CMD node server.js

View File

@ -0,0 +1,51 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker compose reference guide at
# https://docs.docker.com/compose/compose-file/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
environment:
NODE_ENV: production
ports:
- 3000:3000
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker-compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt

35
_deploy/entrypoint.sh Normal file
View File

@ -0,0 +1,35 @@
#!/bin/sh
if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then
apk add git nano rsync
echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME')"
rm -rf /tmp/clone
mkdir /tmp/clone
git clone -b ${GIT_BRANCH:-master} --depth 1 https://$GIT_USERNAME:${GIT_PASSWORD//@/%40}@git.d-popov.com/popov/next-cart-app.git /tmp/clone || exit 1
GIT_COMMIT_ID=$(git -C /tmp/clone/next-cart-app rev-parse HEAD)
LAST_COMMIT_MESSAGE=$(git log -1 --pretty=%B)
echo "Current Git Commit: $LAST_COMMIT_MESSAGE: $GIT_COMMIT_ID"
export GIT_COMMIT_ID
# Use rsync for synchronizing files, ensuring deletion of files not in source
rsync -av --delete /tmp/clone/next-cart-app/ /app/ || echo "Rsync failed: Issue synchronizing files"
rsync -av /tmp/clone/next-cart-app/.env* /app/ || echo "Rsync failed: Issue copying .env files"
rsync -av /tmp/clone/next-cart-app/_deploy/entrypoint.sh /app/entrypoint.sh || echo "Rsync failed: Issue copying entrypoint.sh"
chmod +x /app/entrypoint.sh
# Consider uncommenting the next line to clean up after successful copy
rm -rf /tmp/clone
cd /app
echo "Installing packages in /app"
npm install --no-audit --no-fund --no-optional --omit=optional
yes | npx prisma generate
#npx prisma migrate deploy
echo "Done cloning. Current Git Commit ID: $GIT_COMMIT_ID"
# prod script
# npx next build
# npx next start
fi
echo "Running the main process"
exec "$@"

40
_deploy/prod.Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Dockerfile in _deploy subfolder
FROM node:current-alpine
# Set environment variables for Node.js
ENV NODE_ENV=production
# Create and set the working directory
WORKDIR /app
# Install system dependencies required for building certain npm packages
# Install git if your npm dependencies require it
RUN apk --no-cache add git
# Copy package.json and package-lock.json (or yarn.lock) first to leverage Docker cache
COPY package*.json ./
# Install dependencies, including Prisma if used in your project
RUN npm install --omit=dev
RUN npm install -g dotenv-cli
# Copy the rest of your app's source code from the project root to the container
COPY . .
# Run Prisma generate (if needed)
RUN npx prisma generate
# Copy the entrypoint script and give it execute permissions
COPY _deploy/entrypoint.sh /usr/local/bin/
COPY _deploy/entrypoint.sh ./
RUN chmod +x /usr/local/bin/entrypoint.sh
# Expose a port (e.g., 3000) to access the app
EXPOSE 3000
# Use the entrypoint script as the entrypoint
ENTRYPOINT ["entrypoint.sh"]
# Start the Next.js app
CMD ["npm", "start"]

View File

@ -0,0 +1,65 @@
version: "3.1"
#name: gw02-mysql
services:
db:
image: mariadb
hostname: mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: Kc8P4h573W67Dd
MYSQL_USER: cart # GRANT ALL PRIVILEGES ON database.* TO 'user'@'%' IDENTIFIED BY 'password';
MYSQL_PASSWORD: cartpw2024
MYSQL_DATABASE: cart # This will create a database named 'cart'
stdin_open: true
tty: true
ports:
- 3307:3306
volumes:
- /mnt/data/apps/docker_volumes/cart/database:/var/lib/mysql
# command: >
# sh -c '
# export MYSQL_ROOT_PASSWORD=$$(openssl rand -base64 12);
# echo "Generated MariaDB root password: $$MYSQL_ROOT_PASSWORD";
# docker-entrypoint.sh mysqld
# '
adminer:
image: adminer
restart: always
environment:
- PHP_INI_UPLOAD_MAX_FILESIZE=500M
- PHP_INI_POST_MAX_SIZE=500M
ports:
- 8083:8080
# THE APP
nextjs-app:
image: sachinvashist/nextjs-docker
ports:
- "8005:8005"
volumes:
- /mnt/data/apps/docker_volumes/cart/app:/app
environment:
- NODE_ENV=demo
- DATABASE_URL=mysql://cart:cartpw2024@mariadb:3306/cart
#! entrypoint: ["/bin/sh", "/entrypoint.sh"]
#run: npm install && npx prisma generate && npm run test;
# command: "npx prisma migrate deploy && npx prisma migrate deploy && npm run build && npm run start"
# command: "npx prisma migrate resolve --applied 20231227185411_remove_email_unique_constraint && npx prisma migrate deploy && npx prisma migrate deploy && npm run build && npm run start"
#command: /bin/sh -c "npm install && npx prisma generate && npm run test" # Install dependencies and start the app
command: "npm run test"
# command: >
# /bin/sh -c "
# apt-get update && apt-get install -y git || exit 1;
# if [ ! -d '/app/.git' ]; then
# echo 'Cloning stable branch from git.d-popov.com...';
# git clone -b stable https://git.d-popov.com/repository.git /app || exit 1;
# fi;
# cd /app;
# npm install && npx prisma migrate deploy && npm run build && npm run prod
# "
tty: true
stdin_open: true
# depends_on:
# - mariadb

41
_deploy/setup-mariadb.sh Normal file
View File

@ -0,0 +1,41 @@
#!/bin/sh
# Replace mariadb_root_password, mydb, myuser, and mypassword with your desired root password, MariaDB username, and password.
# Update APK repositories
apk update
# Install MariaDB
apk add mariadb mariadb-client
# Initialize the database
mysql_install_db --user=mysql --ldata=/var/lib/mysql
# Start MariaDB
rc-service mariadb start
# Secure the MariaDB installation (optional but recommended)
mysql_secure_installation <<EOF
y # Set root password? [Y/n]
9F2*M5*43s4& # New password
9F2*M5*43s4& # Repeat password
y # Remove anonymous users? [Y/n]
y # Disallow root login remotely? [Y/n]
y # Remove test database and access to it? [Y/n]
y # Reload privilege tables now? [Y/n]
EOF
# Create a new user and database
mysql -u root -p9F2*M5*43s4& <<EOF
CREATE USER 'cart'@'%' IDENTIFIED BY 'o74x642Rc8%6';
CREATE DATABASE cart;
GRANT ALL PRIVILEGES ON cart.* TO 'cart'@'%';
FLUSH PRIVILEGES;
EOF
# Allow remote connections (optional)
echo "[mysqld]" >> /etc/my.cnf
echo "bind-address = 0.0.0.0" >> /etc/my.cnf
# Restart MariaDB to apply changes
rc-service mariadb restart

27
_deploy/setup-postgres.sh Normal file
View File

@ -0,0 +1,27 @@
#!/bin/sh
# Update APK repositories
apk update
# Install PostgreSQL
apk add postgresql postgresql-client
# Create a new database directory
mkdir -p /var/lib/postgresql/data
# Initialize the database
su - postgres -c "initdb /var/lib/postgresql/data"
# Start PostgreSQL
su - postgres -c "pg_ctl start -D /var/lib/postgresql/data -l logfile"
# Create a new user and database
su - postgres -c "psql -c \"CREATE USER myuser WITH PASSWORD 'mypassword';\""
su - postgres -c "psql -c \"CREATE DATABASE mydb OWNER myuser;\""
# Enable remote connections (optional)
echo "host all all 0.0.0.0/0 md5" >> /var/lib/postgresql/data/pg_hba.conf
echo "listen_addresses='*'" >> /var/lib/postgresql/data/postgresql.conf
# Restart PostgreSQL to apply changes
su - postgres -c "pg_ctl restart -D /var/lib/postgresql/data"

64
_deploy/terraform.tf Normal file
View File

@ -0,0 +1,64 @@
# Virtual Machines BS Series B2s EU West 736.9334 0.0000 736.9334 0.0449 $33,11
# Virtual Network IP Addresses Standard IPv4 Static Public IP 2,217.0000 0.0000 2,217.0000 0.0047 $10,38
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "example" {
name = "example-resources"
location = "East US"
}
resource "azurerm_virtual_network" "example" {
name = "example-network"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
}
resource "azurerm_subnet" "example" {
name = "example-subnet"
resource_group_name = azurerm_resource_group.example.name
virtual_network_name = azurerm_virtual_network.example.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_interface" "example" {
name = "example-nic"
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.example.id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_linux_virtual_machine" "example" {
name = "example-vm"
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
network_interface_ids = [
azurerm_network_interface.example.id,
]
size = "Standard_DS2_v2"
admin_username = "adminuser"
admin_password = "Password1234!"
disable_password_authentication = false
computer_name = "hostname"
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "UbuntuServer"
sku = "18.04-LTS"
version = "latest"
}
}

13
_deploy/test.11.yml Normal file
View File

@ -0,0 +1,13 @@
version: "3"
services:
nextjs-app:
image: node:20.11.0-alpine
ports:
- "5002:3000"
working_dir: /app
volumes:
- /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app:/app
environment:
- NODE_ENV=test
command: /bin/sh -c "npm install && npx prisma generate && npm run test; tail -f /dev/null" # Install dependencies and start the app

View File

@ -0,0 +1,54 @@
# Use a specific version of node to ensure consistent builds
FROM node:current-alpine AS builder
# Set environment variables for Node.js
ENV NODE_ENV=production
# Create and set the working directory
WORKDIR /app
# Install system dependencies required for building certain npm packages
# Install git if your npm dependencies require it
RUN apk --no-cache add git
# Copy package.json and package-lock.json (or yarn.lock) first to leverage Docker cache
COPY package*.json ./
# Optionally, if you're using Prisma, copy the Prisma schema file
# This is necessary for the `prisma generate` command
COPY prisma ./prisma/
# Install dependencies, including Prisma if used in your project
RUN npm install --omit=dev
# Install global packages if necessary
RUN npm install -g dotenv-cli
# Build stage for the actual source code
FROM node:current-alpine AS app
WORKDIR /app
# Copy installed node_modules from builder stage
COPY --from=builder /app/node_modules ./node_modules
# Copy the rest of your app's source code from the project root to the container
COPY . .
# Copy over any generated files from the builder stage if necessary
# For example, the prisma client if you're using Prisma
COPY --from=builder /app/node_modules/.prisma/client /app/node_modules/.prisma/client
# Copy the entrypoint script and give it execute permissions
COPY _deploy/entrypoint.sh /usr/local/bin/
COPY _deploy/entrypoint.sh ./
RUN chmod +x /usr/local/bin/entrypoint.sh
# Expose a port (e.g., 3000) to access the app
EXPOSE 3000
# Use the entrypoint script as the entrypoint
ENTRYPOINT ["entrypoint.sh"]
# Start the Next.js app
CMD ["npm", "start"]

223
_doc/GoogleFormsScript.gs Normal file
View File

@ -0,0 +1,223 @@
function main() {
var form = FormApp.openById("11VuGKjoywNA0ajrfeRVwdNYDXSM0ge2qAn6bhiXOZj0"); // Replace with your Form's ID
var sheet = SpreadsheetApp.openById("17CFpEDZ2Bn5GJSfMUhliwBBdbCaghATxZ5lYwRSbGsY").getSheetByName("Sheet1");
var sheetResults = SpreadsheetApp.openById("1jIW27zh-502WIBpFVWGuNSZp7vBuB-SW6L_NPKgf4Ys").getSheetByName("Form Responses 1");
setNamesDropdownOptions(form, sheet);
//updateWeeklyQuestions(form, new Date(2023,11 - 1,1));
//mergeColumnsWithSameNames(sheetResults);
}
function setNamesDropdownOptions(form, sheet) {
var names = sheet.getRange("A4:A").getValues(); // Replace 'Sheet1' and 'A:A' with your sheet name and range respectively
var colAFontWeights = sheet.getRange("A4:A").getFontWeights();
var flatList = names
.flat()
.map(function (name) {
return normalizeName(name[0]);
})
.filter(String); // Flatten the array, normalize names, and remove any empty strings
var title = "Име, Фамилия";
var item = form.getItems(FormApp.ItemType.LIST); // or FormApp.ItemType.LIST or MULTIPLE_CHOICE
var multipleChoiceItem = item[0].asListItem(); // or .asMultipleChoiceItem()
var boldValues = [];
for (var i = 0; i < colAFontWeights.length; i++) {
if (colAFontWeights[i][0] === "bold") {
boldValues.push(names[i][0]);
}
}
var bulgarianOrder = "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЬЮЯ";
function customBulgarianSort(a, b) {
for (var i = 0; i < Math.min(a.length, b.length); i++) {
var indexA = bulgarianOrder.indexOf(a[i]);
var indexB = bulgarianOrder.indexOf(b[i]);
// Default behavior for characters not in the bulgarianOrder string
if (indexA === -1 && indexB === -1) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
if (indexA === -1) return 1;
if (indexB === -1) return -1;
if (indexA < indexB) return -1;
if (indexA > indexB) return 1;
}
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
boldValues.sort(customBulgarianSort);
multipleChoiceItem.setChoiceValues(boldValues);
}
// Helper function to remove leading, trailing, and consecutive spaces
function normalizeName(name) {
if (typeof name !== "string") {
return ""; // Return an empty string if the name is not a valid string
}
return name.split(" ").filter(Boolean).join(" ");
}
function ReverseNames(names, destination = "D") {
// Reverse the name order for each name
var reversedNames = names.map(function (name) {
var splitName = name[0].trim().split(" ");
if (splitName.length === 2) {
// Ensure there's a Lastname and Firstname to swap
return [splitName[1] + " " + splitName[0]];
} else {
return [name[0]]; // Return the original name if it doesn't fit the "Lastname Firstname" format
}
});
// Write reversed names to column D
sheet.getRange(destination + "4:" + destination + "" + (3 + reversedNames.length)).setValues(reversedNames);
}
function updateWeeklyQuestions(form, forDate) {
var items = form.getItems(FormApp.ItemType.CHECKBOX_GRID);
var today = forDate;
var firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
var weeks = getWeeksInMonth(today.getMonth(), today.getFullYear());
var columns = [];
columns.push("Не мога");
var weekDays = ["пон", "вт", "ср", "четв", "пет", "съб"];
//Summer
//for (var i = 8; i <= 18; i += 2) {
// columns.push("(" + i + "ч-" + (i + 2) + "ч)");
//Winter
for (var i = 9; i <= 18; i += 1.5) {
columns.push("" + formatTime(i) + "-" + formatTime(i + 1.5) + "");
}
for (var i = 0; i < weeks.length; i++) {
var week = weeks[i];
// Skip weeks that started in the previous month
if (week[0].getMonth() !== firstDayOfMonth.getMonth()) {
continue;
}
console.log("Week from " + week[0].getDate());
var title = "Седмица " + (i + 1) + " (" + week[0].getDate() + "-" + week[5].getDate() + " " + getMonthName(week[0].getMonth()) + ")"; // Always represent Monday to Saturday
if (week[5].getMonth() !== week[0].getMonth()) {
title = "Седмица " + (i + 1) + " (" + week[0].getDate() + " " + getMonthName(week[0].getMonth()) + " - " + week[5].getDate() + " " + getMonthName(week[5].getMonth()) + ")"; // Always represent Monday to Saturday
}
if (i < items.length) {
// Modify existing checkbox grid
var checkboxGrid = items[i].asCheckboxGridItem();
checkboxGrid.setTitle(title);
checkboxGrid.setColumns(columns);
checkboxGrid.setRows(weekDays);
} else {
// Create a new checkbox grid
var checkboxGrid = form.addCheckboxGridItem();
checkboxGrid.setTitle(title);
checkboxGrid.setColumns(columns);
checkboxGrid.setRows(weekDays);
checkboxGrid.setRequired(true);
}
}
// Delete extra checkbox grids
for (var j = weeks.length; j < items.length; j++) {
form.deleteItem(items[j]);
}
}
function getWeeksInMonth(month, year) {
var weeks = [],
firstDate = new Date(year, month, 1),
lastDate = new Date(year, month + 1, 0);
// Find the first Monday of the month
while (firstDate.getDay() !== 1) {
firstDate.setDate(firstDate.getDate() + 1);
}
var currentDate = new Date(firstDate);
while (currentDate <= lastDate) {
var week = [];
// For 6 days (Monday to Saturday)
for (var i = 0; i < 6; i++) {
week.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
currentDate.setDate(currentDate.getDate() + 1); //Sunday
weeks.push(week);
// If we are already in the next month, break out of the loop
if (currentDate.getMonth() !== month) {
break;
}
}
return weeks;
}
function getMonthName(monthIndex) {
var months = ["януари", "февруари", "март", "април", "май", "юни", "юли", "август", "септември", "октомври", "ноември", "декември"];
return months[monthIndex];
}
function formatTime(hourDecimal) {
var hours = Math.floor(hourDecimal);
var minutes = (hourDecimal - hours) * 60;
if (minutes === 0) {
return hours.toString().padStart(2, "0");
}
return hours.toString().padStart(2, "0") + ":" + minutes.toString().padStart(2, "0");
}
function mergeColumnsWithSameNames(sheet) {
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
var uniqueHeaders = [...new Set(headers)]; // Get unique headers
for (var u = 0; u < uniqueHeaders.length; u++) {
var currentHeader = uniqueHeaders[u];
var columnsToMerge = [];
// Identify columns with the same name
for (var i = 0; i < headers.length; i++) {
if (headers[i] === currentHeader) {
columnsToMerge.push(i + 1); // '+1' because column indices are 1-based
}
}
if (columnsToMerge.length > 1) {
// If there's more than one column with the same name
var sumData = [];
for (var col of columnsToMerge) {
var columnData = sheet.getRange(2, col, sheet.getLastRow() - 1).getValues(); // '-1' to exclude the header
for (var j = 0; j < columnData.length; j++) {
if (!sumData[j]) sumData[j] = [];
sumData[j][0] = (sumData[j][0] || 0) + (columnData[j][0] || 0);
}
}
// Write back summed data to the first column in the list
sheet.getRange(2, columnsToMerge[0], sumData.length, 1).setValues(sumData);
// Delete the other duplicate columns
for (var k = columnsToMerge.length - 1; k > 0; k--) {
sheet.deleteColumn(columnsToMerge[k]);
}
// Adjust headers array to reflect the deleted columns
headers = headers.slice(0, columnsToMerge[1] - 1).concat(headers.slice(columnsToMerge[columnsToMerge.length - 1], headers.length));
}
}
}

25
_doc/Message.md Normal file
View File

@ -0,0 +1,25 @@
1. Щандове. Предлагаме следното:
А) Да има известно разстояние между нас и щандовете. Целта е да оставим хората свободно да се доближат до количките и ако някой прояви интерес може да се приближим.
Б) Когато сме двама или трима може да стоим заедно. Ако сме четирима би било хубаво да се разделим по двама на количка и количките да са на известно разстояние една от друга.
Моля вижте и прикачените снимки!
2. Безопасност. Нека се страем зад нас винаги да има защита или препятствие за недобронамерени хора.
3. Плакати. Моля при придвижване на количките да слагате плакатите така, че илюстрацията да се вижда, когато калъфа е сложен. Целта е снимките да не се търкат в количката, защото се повреждат.
4. Литература. При проявен интерес на чужд език, използвайте списанията и трактатите на други езици в папките.
5. График. Моля да ни изпратите вашите предпочитания до 23-то число на месеца. Линк към анкетата:
https://docs.google.com/forms/d/e/1FAIpQLSdLpuTi0o9laOYmenGJetY6MJ-CmphD9xFS5ZVz_7ipOxNGUQ/viewform?usp=sf_link
6. Случки. Ако сте имали хубави случки на количките, моля пишете ни.
За други въпроси може да се обръщате към брат Янко Ванчев и брат Крейг Смит.
Много ви благодарим за хубавото сътрудничество и за вашата усърдна работа в службата с подвижните щандове в София! Пожелаваме ви много благословии и радост в този вид служба!
Ваши братя,
Специално свидетелстване на обществени места в София

168
_doc/ToDo.md Normal file
View File

@ -0,0 +1,168 @@
# - Authentication : Done
# - Fix Location form :
- store and generate transport
- fix auto generation
- fix problem with showing Наташа Перчекли available on 7-th november from 10:00-12:30
# - fix loading of availabiities in publisher page
# - fix importing of availabilities from the last week, when the end is in the next month
#? - fix availability start/end borders while searching, as adjasecent shifts are shown as available while they are not
# - unit test proximity search for
+ 'Елена Йедлинска',
+ 'Джейми Лий Смит',
+ 'Стела Стоянова'@ 9th november last shift
+ 'Юрий Чулак' при импорт достъпностите са ВСЯКА сряда
# - защо не се изтриват достъпностите при импорт? заюото не се взимаха от базата за да се итерират
# - why are some availabilities imported as weekly : current/next month logic when importing in the current month
- show weekly availabilities in calendar publisher list?
- importing schedule should create availability if not available. importing preferences deletes old availabilities.
- add option (checkbox) if it is a family availability
- on calendar - when click on a publisher - show his availabilities and assihnments in a popup, with the option to move assingments to another shift - where there are free slots or inconfirmed slots.
for each shift withing availability - show the current slots and allow to drag and drop to another shift. each shift has maximum 4 slots.
- прибиране/докаване на количките - да се импортира
- fix month end not to last sunday, but to last day of the month in availabilities
- да проверя файла с участия
Run `npm audit` for details.
PS D:\DEV\workspace\next-cart-app\next-cart-app> npm update
npm WARN deprecated graphql-import@0.7.1: GraphQL Import has been deprecated and merged into GraphQL Tools, so it will no longer get updates. Use GraphQL Tools instead to stay up-to-date! Check out https://www.graphql-tools.com/docs/migration-from-import for migration and https://the-guild.dev/blog/graphql-tools-v6 for new changes.
npm WARN deprecated uuid@3.4.0: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
npm WARN deprecated subscriptions-transport-ws@0.9.16: The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md
npm WARN deprecated graphql-tools@4.0.5: This package has been deprecated and now it only exports makeExecutableSchema.
npm WARN deprecated And it will no longer receive updates.
npm WARN deprecated We recommend you to migrate to scoped packages such as @graphql-tools/schema, @graphql-tools/utils and etc.
npm WARN deprecated Check out https://www.graphql-tools.com to learn what package you should use instead
added 85 packages, removed 94 packages, changed 50 packages, and audited 1366 packages in 3m
---
три цвята на назначенията: актуални, минали, без потвърждение/от назначенията/автоматични;
- color code shifts: yellow if 3 publishers, red if <=2 publishers, green if 4 publishers
- for a publisher - view all available shfts where there are free slots and allow to move assignments to another shift
- bulk edit for publishers - change type, target shifts.
- edit publisher availabilities in grid - isWithTransport
After meeting with Fabio:
- passed training cb
- option to search for replacements only for my assignments (directly replace, or ask for help - email)
-
+ see the whole schedule - talk with Yanko
- sign up
shift reports - show my shifts, store the report to the shift and norify if report already exists.
!!! make word imports create availability as option during import - create availabilities as tentative and repeat them every month
fix import - it adds it to the next day
elena qdlinska is always created
dariush availabilities are not imported
fix availability repeat checks
Упътване потребители:
Ако искат винаги да са заедно - може да използват един имейл. Ако не - трябва да имат различни имейли.
Упътване отговорен брат:
1 - провери всеки вестител за предпочитания/назначения (календара)
2 - провери графика за изпуснати вестители - в червено, които не са на разположение за назначението си
3 - провери графика за празни смени
4 - провери за пропуснати вестители - без назначения за месеца
5 - да провери транспорта
Цялата програма - като от ПДФ.
меню: пон-петък
"При лошо време"
да се добави: контакти - янко и крейг
чекбоксове,
до края на месеца
динамичен график: да могат да търсят заместници
Рубен да провери вестителите: дали са според програмата.
2024.02.14:
Янко:
1. графика на всички. отделно меню.
2. разрешение от общината. "разрешително". да има и админ за обновяване?
3. линк местоположение
4. местоположение - имена
5. ipad - scroll na PDF-s 148
6. списък с участници.
7. отделен вид разположение "заместник"
Фабио:
1. да може участника да види тези, които са на разопложение и да види телефоните.
2. да изпращат а) на разположение б) на всички
3. желани смени на месец - в "календар"
4. да се импортират данните за назначение.
Краси
1. Push notifications
-- Bugs:
- import all assignments from WORD
- respect repetition of availabilities
- new api to set availabilities per day (deleting the old ones, based on time periods array)
- failover for the server
Fabio:
- може да оставим само тези, които са въвели, но от другия месец, за да можем този ако има проблеми да ги оправим. да не стане така, че не приемаме друг начин, а нещо не работи
- можем да тестваме с 10=20 човека, и ако няма прооблеми да пратим до всички още този месец.
- списък с видове назначение:
Вестител
Бетелит
Редовен Пионер
Специален Пионер
Мисионер
Пътуваща служба
-------------------------------------------------------
Янко:
1. графика на всички. отделно меню.
2. разрешение от общината. "разрешително". да има и админ за обновяване?
3. линк местоположение
4. местоположение - имена
5. ipad - scroll na PDF-s 148
6. списък с участници.
7. отделен вид разположение "заместник"
Фабио:
1. да може участника да види тези, които са на разопложение и да види телефоните.
2. да изпращат а) на разположение б) на всички
3. желани смени на месец - в "календар"
4. да се импортират данните за назначение.
Краси
1. Push notifications
--
Проблеми: някои използват един и същи имейл - няма как да въведат графика и на двамата.
- да оправя дневните разположения
- да се взимат в предвид повтарящите се разопложения
--
- да оправя дневните разположения
- да се взимат в предвид повтарящите се разопложения

53
_doc/additionalNotes.mb Normal file
View File

@ -0,0 +1,53 @@
# GIT clone last commit to current folder:
git clone --depth 1 https://git.d-popov.com/popov/next-cart-app.git .
# git clone only /next-cart-app subfolder (the nextjs app)
# [git init]
# [git remote add origin https://git.d-popov.com/popov/next-cart-app.git]
git config core.sparseCheckout true
echo "next-cart-app/" >> .git/info/sparse-checkout
git clone --depth 1 --branch master https://git.d-popov.com/popov/next-cart-app.git .
#git fetch --depth=1 origin master
#git checkout master
#git reset --hard HEAD
#Build the Docker Image:
docker-compose build
#Tag the Image for the Local Registry:
docker tag pw-cart 192.168.0.10:5000/pw-cart
#Push the Image to the Local Registry
docker push 192.168.0.10:5000/pw-cart
# deploy to azure XXX
mkdir app2
git clone https://git.d-popov.com/popov/next-cart-app.git app2
mv app2 app/
rm -rf app2
# copy to tmp: #rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone
# copy to /app: #rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/
# cleanup #rm -rf /tmp/clone
#[!opt] build: # npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app
# cd /app && npm install
####################### GIT ######################
# git remove file from repo (ignore and delete)
gitignore > + *.zip
git commit -m "Ignore all .zip files"
--
git rm --cached '*.zip'
git commit -m "Remove tracked .zip files from repository"
git push
## git clean/optimize
git gc
git prune
git repack -ad
#!Expire and prune older reflog entries to reduce the number of objects retained for undo purposes.
#git reflog expire --expire=now --all
#git gc --prune=now

78
_doc/auth/auth.md Normal file
View File

@ -0,0 +1,78 @@
location /authelia {
internal;
set $upstream_authelia http://authelia:9091/api/verify;
proxy_pass_request_body off;
proxy_pass $upstream_authelia;
proxy_set_header Content-Length "";
# Timeout if the real server is dead
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
client_body_buffer_size 128k;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Ssl on;
proxy_redirect http:// $scheme://;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 4 32k;
send_timeout 5m;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_connect_timeout 240;
}
location / {
set $upstream_app $forward_scheme://$server:$port;
proxy_pass $upstream_app;
auth_request /authelia;
auth_request_set $target_url https://$http_host$request_uri;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $email $upstream_http_remote_email;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Email $email;
proxy_set_header Remote-Groups $groups;
error_page 401 =302 https://auth.d-popov.com/?rd=$target_url;
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection upgrade;
proxy_set_header Accept-Encoding gzip;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Ssl on;
proxy_redirect http:// $scheme://;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;
set_real_ip_from 172.18.0.0/16;
set_real_ip_from 172.19.0.0/16;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
}

View File

@ -0,0 +1,25 @@
## Send a subrequest to Authelia to verify if the user is authenticated and has permission to access the resource.
auth_request /authelia;
## Set the $target_url variable based on the original request.
## Comment this line if you're using nginx without the http_set_misc module.
set_escape_uri $target_url $scheme://$http_host$request_uri;
## Uncomment this line if you're using NGINX without the http_set_misc module.
# set $target_url $scheme://$http_host$request_uri;
## Save the upstream response headers from Authelia to variables.
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
## Inject the response headers from the variables into the request made to the backend.
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
## If the subreqest returns 200 pass to the backend, if the subrequest returns 401 redirect to the portal.
error_page 401 =302 https://auth.d-popov.com/?redirect=$target_url;

View File

@ -0,0 +1,36 @@
set $upstream_authelia http://authelia:9091/api/verify;
## Virtual endpoint created by nginx to forward auth requests.
location /authelia {
## Essential Proxy Configuration
internal;
proxy_pass $upstream_authelia;
## Headers
## The headers starting with X-* are required.
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Content-Length "";
proxy_set_header Connection "";
## Basic Proxy Configuration
proxy_pass_request_body off;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead
proxy_redirect http:// $scheme://;
proxy_http_version 1.1;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 4 32k;
client_body_buffer_size 128k;
## Advanced Proxy Configuration
send_timeout 5m;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_connect_timeout 240;
}

View File

@ -0,0 +1,7 @@
include /snippets/authelia-location.conf;
location / {
include /snippets/proxy.conf;
include /snippets/authelia-authrequest.conf;
proxy_pass $forward_scheme://$server:$port;
}

35
_doc/auth/proxy.conf Normal file
View File

@ -0,0 +1,35 @@
## Headers
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
## Basic Proxy Configuration
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; ## Timeout if the real server is dead.
proxy_redirect http:// $scheme://;
proxy_http_version 1.1;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;
## Trusted Proxies Configuration
## Please read the following documentation before configuring this:
## https://www.authelia.com/integration/proxies/nginx/#trusted-proxies
# set_real_ip_from 10.0.0.0/8;
# set_real_ip_from 172.16.0.0/12;
# set_real_ip_from 192.168.0.0/16;
# set_real_ip_from fc00::/7;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
## Advanced Proxy Configuration
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;

178
_doc/notes.mb Normal file
View File

@ -0,0 +1,178 @@
Nginx:
Email: admin@example.com
Password: changeme
google auth:
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
# to spin up devcontainer:
# ensure we have docker:
apt-get update && apt-get install -y docker.io
docker build -t dev-next-cart-app-img .devcontainer
docker run -d -v /path/to/your/project:/workspace --name dev-next-cart-app dev-next-cart-app-img
docker exec -it dev-next-cart-app /bin/bash
##### ----------------- compose/deploy ----------------- ###
# install docker if inside docker (vscode-server)# apt-get update && apt-get install -y docker.io
# .10 > /mnt/apps/DEV/SSS/next-cart-app/next-cart-app/
#.11 > cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app
# using dockerfile and image:
docker build -t jwpw:latest -f _deploy/prod.Dockerfile .
# or ----------------------
docker build -t jwpw:test -f _deploy/testBuild.Dockerfile .
#---- or (testing 2 step docker image)
docker build -t docker.d-popov.com/jwpw:test -f _deploy/testBuild.Dockerfile .
docker run -p 3000:3000 docker.d-popov.com/jwpw:test
docker run -it --name browse-jwse --entrypoint sh docker.d-popov.com/jwpw:test
docker rm browse-jwpw
--- TEST
docker build -t docker.d-popov.com/jwpw:test -f _deploy/testBuild.Dockerfile .
docker push docker.d-popov.com/jwpw:test
--LATEST/
cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app
docker build -t docker.d-popov.com/jwpw:latest -f _deploy/prod.Dockerfile .
docker tag docker.d-popov.com/jwpw:latest docker.d-popov.com/jwpw:0.9.94
docker push docker.d-popov.com/jwpw:latest
docker push docker.d-popov.com/jwpw:0.9.94
#---
docker tag jwpw:latest docker.d-popov.com/jwpw:latest
docker push docker.d-popov.com/jwpw:latest # docker push registry.example.com/username/my-image:latest #docker login docker.d-popov.com
# Tag the image for your local Docker registry
docker tag jwpw:0.9 docker.d-popov.com/jwpw:0.9
# deploy
docker pull docker.d-popov.com/jwpw:latest
docker run -p local-port:container-port docker.d-popov.com/jwpw:latest
# docker-compose
docker-compose up --build
!
docker-compose -f test.11.yml up --build # build test on .11:5002
docker-compose -f deploy.azure.yml up --build
# OR git clone ------------------!!!
apk add git && rm -rf /tmp/clone
apk add git && git clone --depth 1 https://popov:6A5FvrV6jfF2BP@git.d-popov.com/popov/next-cart-app.git /tmp/clone && cp -Rf /tmp/clone/next-cart-app/* /app/
# if cloned # cd /tmp/clone && git pull --depth 1
cp -Rf /tmp/clone/next-cart-app/* /app/ && cd /app
cp -Rf /tmp/clone/next-cart-app/* /mnt/docker_volumes/pw/app/
rm -rf /tmp/clone
npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json
NODE_OPTIONS="--max-old-space-size=4096" npm install
npm install && npm install -g dotenv-cli && npx prisma generate
npm run test
# OR
# copy with SSH
# remote: #sudo chown -R azureuser:azureuser /mnt/docker_volumes/pw/app/
# local: #rsync -avz ./node_modules/ azureuser@172.160.240.73:/mnt/docker_volumes/pw/app/node_modules/
# OR (2 cmd deploy PROD)
# npm run build
rsync -avz --include=".*" .next \
.env .env.production \
components \
pages \
prisma \
src \
styles \
package.json \
package-lock.json \
server.js \
azureuser@172.160.240.73:/mnt/docker_volumes/pw/app/
# or DOCKERFILE
docker build -t jw-cart -f prod.Dockerfile .
docker build -t jw-cart-prod -f testBuild.Dockerfile .
#build
npm run build
#install next
npm install -g next
next build
next start
## ------------------------------- dev -----------------------------------###
# aider:
export OPENAI_API_KEY=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 # personal
export OPENAI_API_KEY=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 # dev-bro
# -------------update PRISMA schema/sync database ------------------------ #
# prisma migrate dev --create-only
npx prisma generate
npx prisma migrate dev --name fix_nextauth_schema --create-only
>Prisma Migrate created the following migration without applying it 20231214163235_fix_nextauth_schema
>You can now edit it and apply it by running "prisma migrate dev"
# #production/deploy: npx prisma migrate deploy
## Reintrospect: If you have a database that's in the desired state, but your Prisma schema is out of date, you can use prisma db pull to introspect the database and update your Prisma schema to match the current state of the database.
npx prisma generate
# mark migration as applied:
npx prisma migrate resolve --applied 20240201214719_assignment_add_repeat_frequency
# update prisma package
npm i prisma@latest
npm i @prisma/client@latest
npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work
##########################################
# yml leave the container running:
command: sh -c "npm run test; tail -f /dev/null"
fix build errors:
cd path\to\your\project
--
rmdir /s /q node_modules
del package-lock.json
npm install
--
Remove-Item -Recurse -Force node_modules
Remove-Item package-lock.json
npm install
# -- mysql
# fix
mysql -u root -pi4966cWBtP3xJ7BLsbsgo93C8Q5262
--
mysqld_safe --skip-grant-tables &
mysql -u root
FLUSH PRIVILEGES;
SET PASSWORD FOR 'root'@'localhost' = PASSWORD('i4966cWBtP3xJ7BLsbsgo93C8Q5262');
GRANT ALL PRIVILEGES ON jwpwsofia.* TO 'jwpwsofia'@'%' IDENTIFIED BY 'dwxhns9p9vp248V39xJyRthUsZ2gR9' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON jwpwsofia.* TO 'jwpwsofia'@'172.22.0.3' IDENTIFIED BY 'dwxhns9p9vp248V39xJyRthUsZ2gR9' WITH GRANT OPTION;
FLUSH PRIVILEGES;
exit;
#Install depcheck:
npm install --save-dev depcheck
npx depcheck
npx depcheck --detailed
#Check for Package Updates
npm install -g npm-check-updates
ncu
ncu -u

View File

@ -0,0 +1,27 @@
export default function ConfirmationModal({ isOpen, onClose, onConfirm, message }) {
//export default function ConfirmationModal({ isOpen, onClose, onConfirm, message })
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="bg-white p-4 rounded-md shadow-lg modal-content">
<p className="mb-4">{message}</p>
<button
className="bg-red-500 text-white px-4 py-2 rounded mr-2"
onClick={onConfirm}
>
Потвтрждавам
</button>
<button
className="bg-gray-300 px-4 py-2 rounded"
onClick={onClose}
>
Отказвам
</button>
</div>
<div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div>
</div>
);
};
// const CustomCalendar = ({ month, year, shifts }) => {
// export default function CustomCalendar({ date, shifts }: CustomCalendarProps) {

17
components/DayOfWeek.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react'
const common = require('src/helpers/common');
function DayOfWeek({ onChange, selected, disabled }) {
return (
<select className="textbox form-select px-4 py-2 rounded"
id="dayofweek" name="dayofweek" onChange={onChange} value={selected} autoComplete="off" disabled={disabled}>
{common.DaysOfWeekArray.map((day, index) => (
<option key={day} value={day}>
{common.dayOfWeekNames[index]}
</option>
))}
</select>
)
}
export default DayOfWeek

120
components/ExampleForm.js Normal file
View File

@ -0,0 +1,120 @@
import axiosInstance from '../src/axiosSecure';
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
class ExampleForm extends React.Component {
constructor(props) {
super(props);
this.state = {
urls: {
apiUrl: "/api/data/...",
indexUrl: "/cart/..."
},
isEdit: false,
};
}
componentDidMount() {
if (this.props.id) {
this.fetch(this.props.id);
}
}
fetch = async (id) => {
try {
const { data } = await axiosInstance.get(this.state.urls.apiUrl + id);
this.setState({ item: data });
} catch (error) {
console.error(error);
}
};
render() {
const urls = {
apiUrl: "/api/data/...s",
indexUrl: "/cart/...s"
}
const [item, set] = useState({
isactive: true,
});
const router = useRouter();
useEffect(() => {
const fetch = async (id) => {
try {
const { data } = await axiosInstance.get(urls.apiUrl + id);
set(data);
} catch (error) {
console.error(error);
}
};
if (router.query?.id) {
fetch(parseInt(router.query.id.toString()));
}
console.log("called");
}, [router.query.id]);
handleChange = ({ target }) => {
if (target.name === "isactive") {
set({ ...item, [target.name]: target.checked });
} else if (target.name === "age") {
set({ ...item, [target.name]: parseInt(target.value) });
} else {
set({ ...item, [target.name]: target.value });
}
}
handleSubmit = async (e) => {
e.preventDefault();
try {
if (router.query?.id) {
await axiosInstance.put(urls.apiUrl + router.query.id, {
...item,
});
toast.success("Task Updated", {
position: "bottom-center",
});
} else {
await axiosInstance.post(urls.apiUrl, item);
toast.success("Task Saved", {
position: "bottom-center",
});
}
router.push(indexUrl);
} catch (error) {
toast.error(error.response.data.message);
}
};
return (
<div className="w-full max-w-xs">
<h3>{router.query?.id ? "Edit" : "Create"} Item </h3>
<div className="mb-4">
<div className="form-check">
<input className="checkbox" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={item.isactive} autoComplete="off" />
<label className="label" htmlFor="isactive">
Is Active</label>
</div>
</div>
<div className="panel-actions">
<Link href={urls.indexUrl} className="action-button"> обратно </Link>
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
{router.query?.id ? "Update" : "Create"}
</button>
</div>
</div>
);
}
}

View File

@ -0,0 +1,116 @@
import React, { useState, useEffect, useCallback } from 'react';
import axiosInstance from '../src/axiosSecure'; // Adjust the import path as needed
import { CircularProgress } from '@mui/material'; // Import MUI CircularProgress for loading indicator
const FileUploadWithPreview = ({ name, value, prefix, onUpload, label }) => {
// Function to transform the original image URL to its corresponding thumbnail URL
function transformToThumbnailUrl(originalUrl) {
if (!originalUrl) return null;
if (originalUrl.includes("thumb")) return originalUrl; // If the URL already contains 'thumb', return it as-is
const parts = originalUrl.split('/');
parts.splice(parts.length - 1, 0, 'thumb'); // Insert 'thumb' before the filename
return parts.join('/');
}
// State for the thumbnail URL
const [thumbnail, setThumbnail] = useState(value ? transformToThumbnailUrl(value) : null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false); // New state for tracking upload status
useEffect(() => {
setThumbnail(value ? transformToThumbnailUrl(value) : null);
}, [value]);
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const { files: droppedFiles } = e.dataTransfer;
processFile(droppedFiles[0]); // Process only the first dropped file
}, []);
const processFile = async (file) => {
if (!file) return;
setIsUploading(true); // Start uploading
const formData = new FormData();
formData.append('image', file);
formData.append('prefix', prefix);
setThumbnail("/");
try {
const response = await axiosInstance.post('upload', formData);
const uploadedFile = response.data[0];
// Update state with the new thumbnail URL
setThumbnail(uploadedFile.thumbUrl);
// Invoke callback if provided
if (onUpload) {
onUpload(name, uploadedFile.originalUrl);
}
} catch (error) {
console.error('Error uploading image:', error);
} finally {
setIsUploading(false); // End uploading
}
};
const handleFilesChange = (e) => {
processFile(e.target.files[0]); // Process only the first selected file
};
return (
<div className="p-2 border-dashed border-2 border-gray-200 dark:border-gray-600 rounded-lg">
<label htmlFor={`${name}-file-upload`} className="block text-sm font-medium text-gray-700 mb-1">
{label || 'Upload Image'}
</label>
<input id={`${name}-file-upload`} type="file" accept="image/*" onChange={handleFilesChange} className="hidden" />
<div className={`flex flex-col items-center justify-center p-4 ${isDragging ? 'bg-gray-100 dark:bg-gray-700' : 'bg-white dark:bg-gray-800'}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}>
{isUploading ? (
<CircularProgress /> // Show loading indicator while uploading
) : (
<>
<p className="text-gray-600 dark:text-gray-300 text-xs mb-2">Поставете сникма тук, или изберете файл с бутона</p>
<button className="btn bg-blue-500 hover:bg-blue-700 text-white font-bold text-xs py-1 px-2 rounded"
onClick={(e) => { e.preventDefault(); document.getElementById(`${name}-file-upload`).click(); }}>
Избери сникма
</button>
</>
)}
</div>
{
thumbnail && (
<div className="mt-2 flex justify-center">
<div className="max-w-xs max-h-64 overflow-hidden rounded-md">
<img src={thumbnail} alt="Thumbnail" className="object-contain w-full h-full" />
</div>
</div>
)
}
</div >
);
};
export default FileUploadWithPreview;

196
components/TextEditor.tsx Normal file
View File

@ -0,0 +1,196 @@
import axiosInstance from '../src/axiosSecure';
import dynamic from 'next/dynamic';
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import 'react-quill/dist/quill.snow.css';
//!!! working
//const ReactQuill = dynamic(() => import('react-quill'), { ssr: false });
// const ImageResize = dynamic(() => import('quill-image-resize'), { ssr: false });
const ReactQuill = dynamic(
async () => {
const { default: RQ } = await import('react-quill');
// const { default: ImageUploader } = await import('./ImageUploader/ImageUploader');
const { default: ImageResize } = await import('quill-image-resize');
RQ.Quill.register('modules/imageResize', ImageResize);
return RQ;
},
{
ssr: false,
},
);
// import {
// Dispatch,
// LegacyRef,
// memo,
// SetStateAction,
// useMemo,
// } from 'react';
// interface IQuillEditor extends ReactQuillProps {
// forwardedRef: LegacyRef<ReactQuill>;
// }
// const QuillNoSSRWrapper = dynamic(
// async () => {
// const { default: RQ } = await import('react-quill');
// // eslint-disable-next-line react/display-name
// return function editor({ forwardedRef, ...props }: IQuillEditor) {
// return <RQ ref={forwardedRef} {...props} />;
// };
// },
// { ssr: false }
// );
const formats = [
'header',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'list',
'bullet',
'indent',
'link',
'image',
'code',
'color',
'background',
'code-block',
'align',
];
interface OnChangeHandler {
(e: any): void;
}
type Props = {
value: string;
placeholder: string;
onChange: OnChangeHandler;
prefix: string;
};
const TextEditor: React.FC<Props> = forwardRef((props, ref) => {
const { value, placeholder, onChange, prefix } = props;
const quillRef = useRef(ref);
const handleOnChange = (e) => {
if (quillRef.current) {
const delta = quillRef.current.getEditor().getContents();
const html = quillRef.current.getEditor().getHTML();
onChange(html);
}
};
useImperativeHandle(ref, () => ({
getQuill: () => quillRef.current,
}));
const imageHandler = async () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const uploadPromises = Array.from(input.files).map(async (item) => {
const formData = new FormData();
formData.append('image', item);
formData.append('prefix', prefix);
try {
const response = await axiosInstance.post('upload', formData);
const imageUrl = response.data.imageUrl;
if (quillRef.current) {
// const editor = quillRef.current.getEditor();
const editor = quillRef.current.getQuill(); // Adjust based on your useImperativeHandle setup
const range = editor.getSelection(true);
editor.insertEmbed(range.index, 'image', imageUrl);
return range.index + 1;
}
} catch (error) {
console.error('Error uploading image:', error);
}
});
const cursorPositions = await Promise.all(uploadPromises);
const lastCursorPosition = cursorPositions.pop();
if (lastCursorPosition !== undefined && quillRef.current) {
const editor = quillRef.current.getEditor();
editor.setSelection(lastCursorPosition);
}
};
};
const modules = React.useMemo(
() => ({
toolbar: {
container: [
// [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike', 'blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }, { indent: '-1' }, { indent: '+1' }],
['link', 'image'],
[{ color: [] }, { background: [] }, { align: [] }],
['clean'],
],
// container: [
// [{ 'font': [] }], // Dropdown to select font
// [{ 'size': ['small', false, 'large', 'huge'] }], // Dropdown to select font size
// [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
// ['bold', 'italic', 'underline', 'strike'], // toggled buttons
// [{ 'script': 'sub' }, { 'script': 'super' }], // superscript/subscript
// ['blockquote', 'code-block'],
// [{ 'list': 'ordered' }, { 'list': 'bullet' }],
// [{ 'indent': '-1' }, { 'indent': '+1' }], // indent/outdent
// [{ 'direction': 'rtl' }], // text direction
// [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
// [{ 'align': [] }],
// ['link', 'image', 'video'], // link and image, video
// ['clean'] // remove formatting button
// ],
// we can't fix this so temporary disable it
// handlers: {
// image: imageHandler,
// },
},
imageResize: true
}),
[imageHandler],
);
//test if we ever get the ref!
useEffect(() => {
if (quillRef.current) {
console.log('quillRef.current: ', quillRef.current);
// You can now access the Quill instance directly via quillRef.current.getEditor()
// This is useful for any setup or instance-specific adjustments you might need
}
}, []);
return (
<>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
modules={modules}
formats={formats}
onChange={onChange}
style={{ height: '500px', width: '100%' }}
placeholder={placeholder}
/>
</>
);
// return <QuillNoSSRWrapper forwardedRef={ref} {...props} />;
});
export default TextEditor;

View File

@ -0,0 +1,20 @@
import { signIn } from "next-auth/react"
export default function AccessDenied() {
return (
<>
<h1>Access Denied</h1>
<p>
<a
href="/api/auth/signin"
onClick={(e) => {
e.preventDefault()
signIn()
}}
>
You must be signed in to view this page
</a>
</p>
</>
)
}

View File

@ -0,0 +1,578 @@
import axiosInstance from '../../src/axiosSecure';
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import DayOfWeek from "../DayOfWeek";
const common = require('src/helpers/common');
import { MobileTimePicker } from '@mui/x-date-pickers/MobileTimePicker';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3';
// import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import TextField from '@mui/material/TextField';
import bg from 'date-fns/locale/bg'; // Bulgarian locale
import { bgBG } from '../x-date-pickers/locales/bgBG'; // Your custom translation file
import { ToastContainer } from 'react-toastify';
const fetchConfig = async () => {
const config = await import('../../config.json');
return config.default;
};
/*
// ------------------ data model ------------------
model Availability {
id Int @id @default(autoincrement())
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
publisherId String
name String
dayofweek DayOfWeek
dayOfMonth Int?
weekOfMonth Int?
startTime DateTime
endTime DateTime
isactive Boolean @default(true)
type AvailabilityType @default(Weekly)
isWithTransport Boolean @default(false)
isFromPreviousAssignment Boolean @default(false)
isFromPreviousMonth Boolean @default(false)
repeatWeekly Boolean? // New field to indicate weekly repetition
repeatFrequency Int? // New field to indicate repetition frequency
endDate DateTime? // New field for the end date of repetition
@@map("Availability")
}
*/
//enum for abailability type - day of week or day of month; and array of values
const AvailabilityType = {
WeeklyRecurrance: 'WeeklyRecurrance',
ExactDate: 'ExactDate'
}
//const AvailabilityTypeValues = Object.values(AvailabilityType);
export default function AvailabilityForm({ publisherId, existingItem, inline, onDone, itemsForDay }) {
const [availability, setAvailability] = useState(existingItem || {
publisherId: publisherId || null,
name: "Нов",
dayofweek: "Monday",
dayOfMonth: null,
startTime: "08:00",
endTime: "20:00",
isactive: true,
repeatWeekly: false,
endDate: null,
});
const [items, setItems] = useState(itemsForDay || []); // [existingItem, ...items]
const [selectedType, setSelectedOption] = useState(AvailabilityType.WeeklyRecurrance);
const [isInline, setInline] = useState(inline || false);
const [timeSlots, setTimeSlots] = useState([]);
const [isMobile, setIsMobile] = useState(false);
// Check screen width to determine if the device is mobile
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768); // 768px is a common breakpoint for mobile devices
};
// Call the function to setAvailability the initial state
handleResize();
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup
return () => window.removeEventListener('resize', handleResize);
}, []);
// Inside your component
const [config, setConfig] = useState(null);
useEffect(() => {
fetchConfig().then(config => {
// Use config here to adjust form fields
console.log("UI config: ", config);
setConfig(config);
});
}, []);
const [dataFetched, setDataFetched] = useState(false);
const router = useRouter();
const initialId = existingItem?.id || router.query.id;
const urls = {
apiUrl: "/api/data/availabilities/",
indexUrl: "/cart/availabilities"
};
// Define the minimum and maximum times
const minTime = new Date();
minTime.setHours(8, 0, 0, 0); // 8:00 AM
const maxTime = new Date();
maxTime.setHours(20, 0, 0, 0); // 8:00 PM
//always setAvailability publisherId
useEffect(() => {
availability.publisherId = publisherId;
console.log("availability.publisherId: ", availability.publisherId);
}, [availability]);
if (typeof window !== 'undefined') {
useEffect(() => {
// If component is not in inline mode and there's no existing availability, fetch the availability based on the query ID
// Fetch availability from DB only if it's not fetched yet, and there's no existing availability
if (!isInline && !existingItem && !dataFetched && router.query.id) {
fetchItemFromDB(parseInt(router.query.id.toString()));
setDataFetched(true); // Set data as fetched
}
}, [router.query.id, isInline, existingItem, dataFetched]);
}
// const [isEdit, setIsEdit] = useState(false);
const fetchItemFromDB = async (id) => {
try {
console.log("fetching availability " + id);
const { data } = await axiosInstance.get(urls.apiUrl + id);
data.startTime = formatTime(data.startTime);
data.endTime = formatTime(data.endTime);
setAvailability(data);
console.log(data);
} catch (error) {
console.error(error);
}
};
const handleChange = ({ target }) => {
// const { name, value } = e.target;
// setItem((prev) => ({ ...prev, [name]: value }));
console.log("AvailabilityForm: handleChange: " + target.name + " = " + target.value);
setAvailability({ ...availability, [target.name]: target.value });
}
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (!availability.name) {
// availability.name = "От календара";
availability.name = common.getTimeFomatted(availability.startTime) + "-" + common.getTimeFomatted(availability.endTime);
}
availability.dayofweek = common.getDayOfWeekNameEnEnum(availability.startTime);
if (availability.repeatWeekly) {
availability.dayOfMonth = null;
} else {
availability.endDate = null;
availability.dayOfMonth = availability.startTime.getDate();
}
delete availability.date; //remove date from availability as it is not part of the db model
// ---------------------- CB UI --------------
if (config.checkboxUI.enabled) {
const selectedSlots = timeSlots.filter(slot => slot.isChecked);
// Sort the selected intervals by start time
const sortedSlots = [...selectedSlots].sort((a, b) => a.startTime - b.startTime);
// Group continuous slots
const groupedIntervals = [];
let currentGroup = [sortedSlots[0]];
for (let i = 1; i < sortedSlots.length; i++) {
const previousSlot = currentGroup[currentGroup.length - 1];
const currentSlot = sortedSlots[i];
// Calculate the difference in hours between slots
const difference = (currentSlot.startTime - previousSlot.endTime) / (60 * 60 * 1000);
// Assuming each slot represents an exact match to the increment (1.5 hours), we group them
if (difference === 0) {
currentGroup.push(currentSlot);
} else {
groupedIntervals.push(currentGroup);
currentGroup = [currentSlot];
}
}
// Don't forget the last group
if (currentGroup.length > 0) {
groupedIntervals.push(currentGroup);
}
// Create availability objects from grouped slots
const availabilities = groupedIntervals.map(group => {
const startTime = group[0].startTime;
const endTime = group[group.length - 1].endTime;
return {
publisherId: availability.publisherId,
startTime: startTime,
endTime: endTime,
// isWithTransportIn: slots[0].isWithTransport,
// Add other necessary fields, like isWithTransport if applicable
};
});
//if more than one interval, we delete and recreate the availability, as it is not possble to map them
if (availability.id && availabilities.length > 1) {
await axiosInstance.delete(urls.apiUrl + availability.id);
delete availability.id;
}
// const firstSlotWithTransport = timeSlots[0].checked && timeSlots[0]?.isWithTransport;
// const lastSlotWithTransport = timeSlots[timeSlots.length - 1].checked && timeSlots[timeSlots.length - 1]?.isWithTransport;
availabilities.forEach(async av => {
// expand availability
const avToStore = {
...availability,
...av,
startTime: av.startTime,
endTime: av.endTime,
name: "От календара",
id: undefined,
// isWithTransportIn: firstSlotWithTransport,
// isWithTransportOut: lastSlotWithTransport,
};
console.log("AvailabilityForm: handleSubmit: " + av);
if (availability.id) {
// UPDATE EXISTING ITEM
await axiosInstance.put(urls.apiUrl + availability.id, {
...avToStore,
});
} else {
// CREATE NEW ITEM
await axiosInstance.post(urls.apiUrl, avToStore);
}
handleCompletion(avToStore); // Assuming `handleCompletion` is defined to handle post-save logic
});
}
// ---------------------- TimePicker UI --------------
else {
availability.publisher = { connect: { id: availability.publisherId } };
delete availability.publisherId;
if (availability.id) {
console.log('editing avail# ' + availability.id);
//delete availability.id;
// UPDATE EXISTING ITEM
var itemUpdate = { ...availability, id: undefined };
await axiosInstance.put(urls.apiUrl + availability.id, {
...itemUpdate,
});
toast.success("Task Updated", {
position: "bottom-center",
});
} else {
// CREATE NEW ITEM
console.log('creating new avail: ' + availability);
const response = await axiosInstance.post(urls.apiUrl, availability);
const createdItem = response.data;
availability.id = createdItem.id;
toast.success("Task Saved", {
position: "bottom-center",
});
}
}
handleCompletion(availability);
} catch (error) {
alert("Нещо се обърка. Моля, опитайте отново по-късно.");
toast.error("Нещо се обърка. Моля, опитайте отново по-късно.");
console.error(error.message);
}
};
const handleDelete = async (e) => {
e.preventDefault();
try {
if (availability.id) {
// console.log("deleting publisher id = ", router.query.id, "; url=" + urls.apiUrl + router.query.id);
await axiosInstance.delete(urls.apiUrl + availability.id);
toast.success("Записът изтрит", {
position: "bottom-center",
});
handleCompletion({ deleted: true }); // Assuming handleCompletion is defined and properly handles post-deletion logic
}
} catch (error) {
alert("Нещо се обърка при изтриването. Моля, опитайте отново или се свържете с нас");
console.log(JSON.stringify(error));
toast.error(error.response?.data?.message || "An error occurred");
}
};
const handleCompletion = async (result) => {
console.log("AvailabilityForm: handleCompletion");
if (isInline) {
if (onDone) {
onDone(result);
}
} else {
router.push(urls.indexUrl);
}
}
console.log("AvailabilityForm: publisherId: " + availability.publisherId + ", id: " + availability.id, ", inline: " + isInline);
const generateTimeSlots = (start, end, increment, item) => {
const slots = [];
// Ensure we're working with the correct date base
const baseDate = new Date(item?.startTime || new Date());
baseDate.setHours(start, 0, 0, 0); // Set start time on the base date
let currentTime = baseDate.getTime();
const endDate = new Date(item?.startTime || new Date());
endDate.setHours(end, 0, 0, 0); // Set end time on the same date
const endTime = endDate.getTime();
// Parse availability's startTime and endTime into Date objects for comparison
const itemStartDate = new Date(item?.startTime);
const itemEndDate = new Date(item?.endTime);
while (currentTime < endTime) {
let slotStart = new Date(currentTime);
let slotEnd = new Date(currentTime + increment * 60 * 60 * 1000); // Calculate slot end time
// Check if the slot overlaps with the availability's time range
const isChecked = slotStart < itemEndDate && slotEnd > itemStartDate;
slots.push({
startTime: slotStart,
endTime: slotEnd,
isChecked: isChecked,
});
currentTime += increment * 60 * 60 * 1000; // Move to the next slot
}
slots[0].isFirst = true;
slots[slots.length - 1].isLast = true;
slots[0].isWithTransport = item.isWithTransportIn;
slots[slots.length - 1].isWithTransport = item.isWithTransportOut;
return slots;
};
const TimeSlotCheckboxes = ({ slots, setSlots, item }) => {
const [allDay, setAllDay] = useState(false);
const handleAllDayChange = (e) => {
const updatedSlots = slots.map(slot => ({
...slot,
isChecked: e.target.checked,
}));
setSlots(updatedSlots);
console.log("handleAllDayChange: allDay: " + allDay + ", updatedSlots: " + JSON.stringify(updatedSlots));
};
useEffect(() => {
console.log("allDay updated to: ", allDay);
const updatedSlots = slots.map(slot => ({
...slot,
isChecked: allDay
}));
//setSlots(updatedSlots);
}, [allDay]);
const handleSlotCheckedChange = (changedSlot) => {
const updatedSlots = slots.map(slot => {
if (slot.startTime === changedSlot.startTime && slot.endTime === changedSlot.endTime) {
return { ...slot, isChecked: !slot.isChecked };
}
return slot;
});
// If slot is either first or last and it's being unchecked, also uncheck and disable transport
if ((changedSlot.isFirst || changedSlot.isLast) && !changedSlot.isChecked) {
changedSlot.isWithTransport = false;
}
setSlots(updatedSlots);
};
const handleTransportChange = (changedSlot) => {
const updatedSlots = slots.map(slot => {
if (slot.startTime === changedSlot.startTime && slot.endTime === changedSlot.endTime) {
return { ...slot, isWithTransport: !slot.isWithTransport };
}
return slot;
});
setSlots(updatedSlots);
};
return (
<>
<label className="checkbox-container flex items-center mb-4">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} className="form-checkbox h-5 w-5 text-gray-600 mx-2" />
Цял ден
<span className="checkmark"></span>
</label>
{slots.map((slot, index) => {
const slotLabel = `${slot.startTime.getHours()}:${slot.startTime.getMinutes() === 0 ? '00' : slot.startTime.getMinutes()} до ${slot.endTime.getHours()}:${slot.endTime.getMinutes() === 0 ? '00' : slot.endTime.getMinutes()}`;
slot.transportNeeded = slot.isFirst || slot.isLast;
// Determine if the current slot is the first or the last
return (
<div key={index} className="mb-4 flex justify-between items-center">
<label className={`checkbox-container flex items-center mb-2 ${allDay ? 'opacity-50' : ''}`}>
<input type="checkbox" checked={slot.isChecked || allDay} onChange={() => handleSlotCheckedChange(slot)}
disabled={allDay}
className="form-checkbox h-5 w-5 text-gray-600 mx-2" />
{slotLabel}
<span className="checkmark"></span>
</label>
{/* Conditionally render transport checkbox based on slot being first or last */}
{slot.transportNeeded && (
<label className={`checkbox-container flex items-center ${(!slot.isChecked || allDay) ? 'opacity-50' : ''}`}>
<input type="checkbox"
className="form-checkbox h-5 w-5 text-gray-600 mx-2"
checked={slot.isWithTransport}
disabled={!slot.isChecked || allDay}
onChange={() => handleTransportChange(slot)} />
{slot.isFirst ? 'Вземане' : 'Връщане'}
<span className="checkmark"></span>
</label>
)}
</div>
);
})}
</>
);
};
useEffect(() => {
setTimeSlots(generateTimeSlots(9, 18, 1.5, availability));
}, []);
return (
<div style={{ width: isMobile ? '90%' : 'max-w-xs', margin: '0 auto' }} >
<ToastContainer></ToastContainer>
<form id="formAv" className="form p-5 bg-white shadow-md rounded-lg p-8 pr-12" onSubmit={handleSubmit}>
<h3 className="text-xl font-semibold mb-5 text-gray-800 border-b pb-2">
{availability.id ? "Редактирай" : "Създай"} Достъпност
</h3>
<LocalizationProvider dateAdapter={AdapterDateFns} localeText={bgBG} adapterLocale={bg}>
<div className="mb-4">
<DatePicker label="Изберете дата" value={availability.startTime} onChange={(value) => setAvailability({ ...availability, endTime: value })} />
</div>
<div>
{config?.checkboxUI && config.checkboxUI.enabled ? (
<div className="mb-4">
{/* Time slot checkboxes */}
<TimeSlotCheckboxes slots={timeSlots} setSlots={setTimeSlots} item={availability} />
</div>
) : (
<>
{/* Start Time Picker */}
<div className="mb-4">
<MobileTimePicker label="От" minutesStep={15} value={availability.startTime} minTime={minTime} maxTime={maxTime}
onChange={(value) => setAvailability({ ...availability, startTime: value })} />
</div>
{/* End Time Picker */}
<div className="mb-4">
<MobileTimePicker label="До" minutesStep={15} value={availability.endTime} minTime={minTime} maxTime={maxTime}
onChange={(value) => setAvailability({ ...availability, endTime: value })} />
</div>
<div className="mb-4">
<label className="checkbox-container">
<input type="checkbox" checked={availability.isWithTransport} className="form-checkbox h-5 w-5 text-gray-600 mx-2"
onChange={() => setAvailability({ ...availability, isWithTransport: !availability.isWithTransport })} />
мога да взема/върна количките
<span className="checkmark"></span>
</label>
</div>
</>
)}
</div>
<div className="mb-4">
<label className="checkbox-container">
<input type="checkbox" checked={availability.repeatWeekly} className="form-checkbox h-5 w-5 text-gray-600 mx-2"
onChange={() => setAvailability({ ...availability, repeatWeekly: !availability.repeatWeekly })} />
Повтаряй всяка {' '}
{/* {availability.repeatWeekly && (
<select
style={{
appearance: 'none',
MozAppearance: 'none',
WebkitAppearance: 'none',
border: 'black solid 1px',
background: 'transparent',
padding: '0 4px',
margin: '0 2px',
height: 'auto',
fontSize: '16px', // Adjust to match surrounding text
textAlign: 'center',
color: 'inherit',
}}
// className="appearance-none border border-black bg-transparent px-1 py-0 mx-0 mr-1 h-auto text-base text-center text-current align-middle cursor-pointer"
//className="form-select mx-2 h-8 text-gray-600"
value={availability.repeatFrequency || 1}
onChange={(e) => setAvailability({ ...availability, repeatFrequency: parseInt(e.target.value, 10) })}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
)} */}
седмица
<span className="checkmark"></span>
</label>
</div>
{availability.repeatWeekly && (
<div className="mb-4">
<DatePicker label="До" value={availability.endDate} onChange={(value) => setAvailability({ ...availability, endDate: value })} />
</div>
)}
</LocalizationProvider>
<div className="mb-4 hidden">
<div className="form-check">
<input className="checkbox form-input" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={availability.isactive} autoComplete="off" />
<label className="label" htmlFor="isactive">активно</label>
</div>
</div>
{/* <input type="hidden" name="isactive" value={availability.isactive} /> */}
<div className="panel-actions">
<button className="action-button" onClick={() => handleCompletion()}> обратно </button>
{availability.id && (
<><button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={handleDelete}>
Изтрий
</button></>
)}
<button
className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline"
> {availability.id ? "Обнови" : "Запиши"}
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,139 @@
import axiosInstance from '../../src/axiosSecure';
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
import { DayOfWeek } from "../DayOfWeek";
const common = require('src/helpers/common');
import AvailabilityForm from "../availability/AvailabilityForm";
import { TrashIcon, PencilSquareIcon } from "@heroicons/react/24/outline";
export default function AvailabilityList({ publisher, showNew }) {
const [showAv, setShowAv] = useState(showNew || false);
const [selectedItem, setSelectedItem] = useState(null);
const [items, setItems] = useState(publisher.availabilities); // Convert items prop to state
useEffect(() => {
console.log('items set to:', items?.map(item => item.id));
}, [items])
const toggleAv = () => setShowAv(!showAv);
const editAvailability = (item) => {
setSelectedItem(item);
setShowAv(true)
};
const deleteAvailability = async (id) => {
try {
await axiosInstance.delete("/api/data/availabilities/" + id);
// Handle the successful deletion, maybe refresh the list or show a success message
const updatedItems = items.filter(item => item.id !== id);
setItems(updatedItems);
} catch (error) {
// Handle the error, maybe show an error message
console.error("Error deleting availability:", error);
}
};
const renderTable = () => (
<table className="min-w-full">
<thead className="border-b">
<tr>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
Ден от седмицата (дата)
</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
От-до
</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
Действия
</th>
</tr>
</thead>
<tbody>
{items?.sort((a, b) => new Date(a.startTime) - new Date(b.startTime)).map(item => (
<tr key={item.id} availability={item} disabled={!item.isactive} >
<td className="px-6 py-4 whitespace-nowrap">
{item.dayOfMonth ? `${common.getDateFormated(new Date(item.startTime))}` : `Всеки(Всяка) ${common.getDayOfWeekName(new Date(item.startTime))}`}
{/* {common.getDateFormated(new Date(item.startTime))} */}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{common.getTimeRange(new Date(item.startTime), new Date(item.endTime))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button className="bg-blue-200 hover:bg-blue-300 text-blue-600 py-1 px-2 rounded inline-flex items-center" onClick={() => editAvailability(item)}>
<PencilSquareIcon className="h-6 w-6" />
</button>
<button className="bg-red-200 hover:bg-red-300 text-red-600 py-1 px-2 rounded ml-2 inline-flex items-center" onClick={() => deleteAvailability(item.id)}>
<TrashIcon className="h-6 w-6" />
</button>
</td>
</tr>
))}
</tbody>
</table>
);
return (
<>
{items?.length === 0 ? (
<h1>No Availabilities</h1>
) : renderTable()}
{<div className="flex justify-center mt-2">
<button className="btn bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded transition duration-300"
onClick={() => { setSelectedItem(null); setShowAv(true) }}>Ново разположение</button>
</div>
}
<div className="h-4 p-10">
{showAv && (
<AvailabilityForm
publisherId={publisher.id}
inline={true}
existingItem={selectedItem}
onDone={(item) => {
toggleAv();
setSelectedItem(null);
if (!item) {
// remove selected item from state
const updatedItems = items.filter(i => i.id !== selectedItem.id);
setItems([...updatedItems]);
return;
};
const itemIndex = items.findIndex(i => i.id === item.id); // assuming each item has a unique 'id' property
if (itemIndex !== -1) {
// Replace the existing item with the updated item
const updatedItems = [...items];
updatedItems[itemIndex] = item;
setItems(updatedItems);
} else {
// Append the new item to the end of the list
setItems([...items, item]);
}
}}
/>
)}
</div>
</>
);
}
export const getServerSideProps = async () => {
const { data: items } = await axiosInstance.get(
common.getBaseUrl("/api/data/availabilities")
);
return {
props: {
items,
},
};
};

View File

@ -0,0 +1,100 @@
// import React, { useState, useEffect } from 'react';
// //import gapi from 'gapi';
// // // import { Document, Page } from 'react-pdf';
// // const CLIENT_ID = '926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com';
// // const API_KEY = 'AIzaSyBUtqjxvCLv2GVcVFEPVym7vRtVW-qP4Jw';
// // const DISCOVERY_DOCS = ["https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"];
// // const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
const GoogleDriveFolderPreview = ({ folderId }) => {
// const folderUrl = `https://drive.google.com/drive/folders/${folderId}`;
// const [files, setFiles] = useState([]);
// useEffect(() =>
// fetch(folderUrl)
// .then(res => res.text())
// .then(text => {
// const parser = new DOMParser();
// const htmlDocument = parser.parseFromString(text, 'text/html');
// const fileElements = htmlDocument.querySelectorAll('.Q5txwe');
// const files = Array.from(fileElements).map(fileElement => {
// const fileUrl = fileElement.getAttribute('href');
// const fileName = fileElement.querySelector('.Q5txwe').textContent;
// return { fileUrl, fileName };
// });
// setFiles(files);
// })
// , [folderUrl]);
// return (
// <div className="grid grid-cols-3 gap-4">
// {files.map(file => (
// <div key={file.fileUrl} className="pdf-preview">
// <iframe src={`https://drive.google.com/file/d/${file.fileUrl}/preview`} width="640" height="480"></iframe>
// </div>
// ))}
// </div>
// );
// // useEffect(() => {
// // // Initialize the Google API client library
// // gapi.load('client:auth2', () => {
// // gapi.client.init({
// // apiKey: API_KEY,
// // clientId: CLIENT_ID,
// // discoveryDocs: DISCOVERY_DOCS,
// // scope: SCOPES
// // }).then(() => {
// // // Listen for sign-in state changes and handle the signed-in state
// // gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);
// // updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
// // });
// // });
// // const updateSigninStatus = (isSignedIn) => {
// // if (isSignedIn) {
// // fetchFiles();
// // } else {
// // gapi.auth2.getAuthInstance().signIn();
// // }
// // };
// // const fetchFiles = async () => {
// // const response = await gapi.client.drive.files.list({
// // q: `'${folderId}' in parents and mimeType = 'application/pdf'`,
// // fields: 'files(id, name, webContentLink)'
// // });
// // setFiles(response.result.files);
// // };
// // }, [folderId]);
// // const PdfDocument = ({ file }) => {
// // return (
// // <Document file={file}>
// // <Page pageNumber={1} />
// // {/* Render other pages as needed */}
// // </Document>
// // );
// // };
// // return (
// // <div className="grid grid-cols-3 gap-4">
// // {files.map(file => (
// // <div key={file.id} className="pdf-preview">
// // <PdfDocument fileUrl={file.url} />
// // </div>
// // ))}
// // </div>
// // );
};
export default GoogleDriveFolderPreview;

View File

@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import axiosInstance from '../../src/axiosSecure';
import PublisherSearchBox from '../publisher/PublisherSearchBox'; // Update the path
const common = require('src/helpers/common');
interface ModalProps {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
forDate: Date;
useFilterDate: boolean;
onUseFilterDateChange: (value: boolean) => void;
}
function Modal({ children, isOpen, onClose, forDate, useFilterDate, onUseFilterDateChange }: ModalProps) {
if (!isOpen) return null;
const isValidDate = forDate instanceof Date && !isNaN(forDate.getTime());
console.log("forDate", forDate, isValidDate);
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="bg-white p-4 rounded-md shadow-lg modal-content">
{isValidDate && (
<h2 className="text-xl font-bold mb-2">
<label className="cursor-pointer">
<input
type="checkbox"
checked={useFilterDate}
onChange={(e) => onUseFilterDateChange(e.target.checked)}
/>
{` на разположение ${common.getDateFormated(forDate)} или ${common.getDayOfWeekName(forDate)}`}
</label>
</h2>
)}
{children}
<button type="button" onClick={onClose} className="mt-4 text-red-500">
Close
</button>
</div>
<div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div>
</div>
);
}
function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, allPublishersInfo }) {
const [assignments, setAssignments] = useState(shift.assignments);
const [isModalOpen, setIsModalOpen] = useState(false);
const [useFilterDate, setUseFilterDate] = useState(true);
const [selectedPublisher, setSelectedPublisher] = useState(null);
const [showCopyHint, setShowCopyHint] = useState(false);
// Update assignments when shift changes
useEffect(() => {
setAssignments(shift.assignments);
}, [shift.assignments]);
const handleShiftClick = (shiftId) => {
// console.log("onShiftSelect prop:", onShiftSelect);
// console.log("Shift clicked:", shift);
//shift.selectedPublisher = selectedPublisher;
if (onShiftSelect) {
onShiftSelect(shift);
}
};
const handlePublisherClick = (publisher) => {
//toggle selected
// if (selectedPublisher != null) {
// setSelectedPublisher(null);
// }
// else {
setSelectedPublisher(publisher);
console.log("Publisher clicked:", publisher, "selected publisher:", selectedPublisher);
shift.selectedPublisher = publisher;
if (onShiftSelect) {
onShiftSelect(shift);
}
// if (onPublisherSelect) {
// onPublisherSelect(publisher);
// }
}
const removeAssignment = async (id) => {
try {
console.log("Removing assignment with id:", id);
await axiosInstance.delete("/api/data/assignments/" + id);
setAssignments(prevAssignments => prevAssignments.filter(ass => ass.id !== id));
} catch (error) {
console.error("Error removing assignment:", error);
}
};
const addAssignment = async (publisher, shiftId) => {
try {
console.log(`new assignment for publisher ${publisher.id} - ${publisher.firstName} ${publisher.lastName}`);
const newAssignment = {
publisher: { connect: { id: publisher.id } },
shift: { connect: { id: shiftId } },
isactive: true,
isConfirmed: true
};
const { data } = await axiosInstance.post("/api/data/assignments", newAssignment);
// Update the 'publisher' property of the returned data with the full publisher object
data.publisher = publisher;
setAssignments(prevAssignments => [...prevAssignments, data]);
} catch (error) {
console.error("Error adding assignment:", error);
}
};
const copyAllPublisherNames = () => {
const names = assignments.map(ass => `${ass.publisher.firstName} ${ass.publisher.lastName}`).join(', ');
common.copyToClipboard(null, names);
// Show hint and set a timer to hide it
setShowCopyHint(true);
setTimeout(() => setShowCopyHint(false), 1500);
};
return (
<div className={`flow w-full p-4 py-2 border-2 border-gray-300 rounded-md my-1 ${isSelected ? 'bg-gray-200' : ''}`}
onClick={handleShiftClick} onDoubleClick={copyAllPublisherNames}>
{/* Time Window Header */}
<div className="flex justify-between items-center mb-2 border-b pb-1">
<span className="text-lg font-semibold">
{`${common.getTimeRange(new Date(shift.startTime), new Date(shift.endTime))}`}
</span>
{/* Copy All Names Button */}
<button onClick={copyAllPublisherNames} className="bg-green-500 text-white py-1 px-2 text-sm rounded-md">
копирай имената {/* Placeholder for Copy icon */}
</button>
{/* Hint Message */}
{showCopyHint && (
<div className="absolute top-0 right-0 p-2 bg-green-200 text-green-800 rounded">
Имената са копирани
</div>
)}
</div>
{/* Assignments */}
{assignments.map((ass, index) => {
const publisherInfo = allPublishersInfo.find(info => info?.id === ass.publisher.id) || ass.publisher;
// Determine border styles
let borderStyles = '';
//if there is no publisherInfo - draw red border - publisher is no longer available for the day!
if (!publisherInfo.availabilities || publisherInfo.availabilities.length == 0) {
borderStyles = 'border-2 border-red-500 ';
}
else {
//pub is not available for that shift assignment.
if (publisherInfo.availabilities?.length === 0 ||
publisherInfo.availabilities?.every(avail => avail.isFromPreviousAssignment)) {
borderStyles += 'border-l-3 border-r-3 border-orange-500 '; // Top border for manual publishers
}
// checkig if the publisher is available for this assignment
if (publisherInfo.availabilities?.some(av =>
av.startTime <= ass.startTime &&
av.endTime >= ass.endTime)) {
borderStyles += 'border-t-2 border-red-500 '; // Left border for specific availability conditions
}
//the pub is the same time as last month
// if (publisherInfo.availabilities?.some(av =>
// (!av.dayOfMonth || av.isFromPreviousMonth) &&
// av.startTime <= ass.startTime &&
// av.endTime >= ass.endTime)) {
// borderStyles += 'border-t-2 border-yellow-500 '; // Left border for specific availability conditions
// }
if (selectedPublisher && selectedPublisher.id === ass.publisher.id) {
borderStyles += 'border-2 border-blue-300'; // Bottom border for selected publishers
}
if (publisherInfo.hasUpToDateAvailabilities) {
//add green right border
borderStyles += 'border-r-2 border-green-300';
}
}
return (
<div key={index}
className={`flow space-x-2 rounded-md px-2 py-1 my-1 ${ass.isConfirmed ? 'bg-yellow-100' : 'bg-gray-100'} ${borderStyles}`}
>
<div className="flex justify-between items-center" onClick={() => handlePublisherClick(ass.publisher)}>
<span className="text-gray-700">{publisherInfo.firstName} {publisherInfo.lastName}</span>
<button onClick={() => removeAssignment(ass.id)}
className="text-white bg-red-500 hover:bg-red-600 px-3 py-1 ml-2 rounded-md"
>
махни
</button>
</div>
</div>
);
})}
{/* This is a placeholder for the dropdown to add a publisher. You'll need to implement or integrate a dropdown component */}
<div className="flex space-x-2 items-center">
{/* Add Button */}
<button onClick={() => setIsModalOpen(true)} className="bg-blue-500 text-white p-2 py-1 rounded-md">
добави {/* Placeholder for Add icon */}
</button>
</div>
{/* Modal for Publisher Search
forDate={new Date(shift.startTime)}
*/}
<Modal isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
forDate={new Date(shift.startTime)}
useFilterDate={useFilterDate}
onUseFilterDateChange={(value) => setUseFilterDate(value)}>
<PublisherSearchBox
selectedId={null}
isFocused={isModalOpen}
filterDate={useFilterDate ? new Date(shift.startTime) : null}
onChange={(publisher) => {
// Add publisher as assignment logic
setIsModalOpen(false);
addAssignment(publisher, shift.id);
}}
showAllAuto={true}
showSearch={true}
showList={false}
/>
</Modal>
</div>
);
}
export default ShiftComponent;

View File

@ -0,0 +1,478 @@
import React, { useState, useEffect } from 'react';
import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import AvailabilityForm from '../availability/AvailabilityForm';
import common from '../../src/helpers/common';
import { toast } from 'react-toastify';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import moment from 'moment';
import 'moment/locale/bg'; // Import Bulgarian locale
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
import { FaArrowLeft, FaArrowRight, FaRegCalendarAlt, FaRegListAlt, FaRegCalendarCheck } from 'react-icons/fa';
import { MdToday } from 'react-icons/md';
import { useSwipeable } from 'react-swipeable';
import axiosInstance from '../../src/axiosSecure';
// Set moment to use the Bulgarian locale
moment.locale('bg');
const localizer = momentLocalizer(moment);
// Bulgarian translations for Calendar labels
const messages = {
allDay: 'Цял ден',
previous: 'Предишен',
next: 'Следващ',
today: 'Днес',
month: 'Месец',
week: 'Седмица',
day: 'Ден',
agenda: 'Дневен ред',
date: 'Дата',
time: 'Час',
event: 'Събитие', // or 'Събитие' depending on context
// Any other labels you want to translate...
};
const AvCalendar = ({ publisherId, events, selectedDate }) => {
const [date, setDate] = useState(new Date());
const [currentView, setCurrentView] = useState('month');
const [evts, setEvents] = useState(events); // Existing events
const [displayedEvents, setDisplayedEvents] = useState(evts); // Events to display in the calendar
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [visibleRange, setVisibleRange] = useState(() => {
const start = new Date();
start.setDate(1); // Set to the first day of the current month
const end = new Date(start.getFullYear(), start.getMonth() + 1, 0); // Last day of the current month
return { start, end };
});
// Update internal state when `events` prop changes
useEffect(() => {
setEvents(events);
// Call any function here to process and set displayedEvents
// based on the new events, if necessary
}, [events]);
const onRangeChange = (range) => {
if (Array.isArray(range)) {
// For week and day views, range is an array of dates
setVisibleRange({ start: range[0], end: range[range.length - 1] });
} else {
// For month view, range is an object with start and end
setVisibleRange(range);
}
};
useEffect(() => {
if (currentView === 'agenda') {
const filtered = evts?.filter(event => event.type === "assignment");
setDisplayedEvents(filtered);
} else {
// Function to generate weekly occurrences of an event
const recurringEvents = evts?.filter(event => event.type !== "assignment" && (event.dayOfMonth === null || event.dayOfMonth === undefined)) || [];
const occurrences = recurringEvents?.flatMap(event => generateOccurrences(event, visibleRange.start, visibleRange.end)) || [];
const nonRecurringEvents = evts?.filter(event => event.dayOfMonth !== null) || [];
setDisplayedEvents([...nonRecurringEvents, ...recurringEvents, ...occurrences]);
}
//setDisplayedEvents(evts);
}, [visibleRange, evts, currentView]);
const handlers = useSwipeable({
onSwipedLeft: () => navigate('NEXT'),
onSwipedRight: () => navigate('PREV'),
preventDefaultTouchmoveEvent: true,
trackMouse: true,
});
const navigate = (action) => {
console.log('navigate', action);
setDate((currentDate) => {
const newDate = new Date(currentDate);
if (action === 'NEXT') {
newDate.setMonth(newDate.getMonth() + 1);
} else if (action === 'PREV') {
newDate.setMonth(newDate.getMonth() - 1);
}
return newDate;
});
};
const generateOccurrences = (event, start, end) => {
const occurrences = [];
const eventStart = new Date(event.startTime);
let current = new Date(event.startTime); // Assuming startTime has the start date
// Determine the end date for the event series
const seriesEndDate = event.endDate ? new Date(event.endDate) : end;
seriesEndDate.setHours(23, 59, 59); // Set to the end of the day
while (current <= seriesEndDate && current <= end) {
// Check if the event should be repeated weekly or on a specific day of the month
if (event.repeatWeekly && current.getDay() === eventStart.getDay()) {
// For weekly recurring events
addOccurrence(event, current, occurrences);
} else if (event.dayOfMonth && current.getDate() === event.dayOfMonth) {
// For specific day of month events
addOccurrence(event, current, occurrences);
}
// Move to the next day
current = new Date(current.setDate(current.getDate() + 1));
}
return occurrences;
};
// Helper function to add an occurrence
const addOccurrence = (event, current, occurrences) => {
// Skip the original event date
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
if (current.toDateString() !== eventStart.toDateString()) {
const occurrence = {
...event,
startTime: new Date(current.setHours(eventStart.getHours(), eventStart.getMinutes())),
endTime: new Date(current.setHours(eventEnd.getHours(), eventEnd.getMinutes())),
date: current,
type: 'recurring'
};
occurrences.push(occurrence);
}
};
// Define min and max times
const minHour = 8; // 8:00 AM
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 = ({ start, end }) => {
if (!start || !end) return;
if (start < new Date() || end < new Date() || start > end) return;
// Check if start and end are on the same day
if (start.toDateString() !== end.toDateString()) {
end = common.setTimeHHmm(start, "23:59");
}
const startMinutes = common.getTimeInMinutes(start);
const endMinutes = common.getTimeInMinutes(end);
// Adjust start and end times to be within min and max hours
if (startMinutes < common.getTimeInMinutes(common.setTimeHHmm(start, minHour))) {
start = common.setTimeHHmm(start, minHour);
}
if (endMinutes > common.getTimeInMinutes(common.setTimeHHmm(end, maxHour))) {
end = common.setTimeHHmm(end, maxHour);
}
setDate(start);
// get exising events for the selected date
const existingEvents = evts?.filter(event => event.publisherId === publisherId && event.startTime === start.toDateString());
setSelectedEvent({
date: start,
startTime: start,
endTime: end,
dayOfMonth: start.getDate(),
isactive: true,
publisherId: publisherId,
// Add any other initial values needed
//set dayOfMonth to null, so that we repeat the availability every week
dayOfMonth: null,
});
setIsModalOpen(true);
};
const handleEventClick = (event) => {
if (event.type === "assignment") return;
// Handle event click
const eventForEditing = {
...event,
startTime: new Date(event.startTime),
endTime: new Date(event.endTime),
publisherId: event.publisherId || event.publisher?.connect?.id,
repeatWeekly: event.repeatWeekly || false,
};
//strip title, start, end and allDay properties
delete eventForEditing.title;
delete eventForEditing.start;
delete eventForEditing.end;
delete eventForEditing.type;
delete eventForEditing.publisher
console.log("handleEventClick: " + eventForEditing);
setSelectedEvent(eventForEditing);
setIsModalOpen(true);
};
const handleDialogClose = async (dialogEvent) => {
setIsModalOpen(false);
if (dialogEvent === null || dialogEvent === undefined) {
} else {
// if (dialogEvent.deleted) {
// // Remove the old event from the calendar
// setEvents(currentEvents => currentEvents.filter(e => e.id !== selectedEvent.id));
// }
// else {
// // Update the event data
// dialogEvent.start = dialogEvent.startTime;
// dialogEvent.end = dialogEvent.endTime;
// // Update the events array by first removing the old event and then adding the updated one
// setEvents(currentEvents => {
// const filteredEvents = currentEvents?.filter(e => e.id !== selectedEvent.id) || [];
// return [...filteredEvents, dialogEvent];
// });
// }
//refresh the events from the server
let events = await axiosInstance.get(`/api/?action=getCalendarEvents&publisherId=${publisherId}`);
var newEvents = events.data;
setEvents(newEvents);
}
console.log("handleSave: ", dialogEvent);
};
const handleCancel = () => {
setIsModalOpen(false);
};
const EventWrapper = ({ event, style }) => {
const [isHovered, setIsHovered] = useState(false);
let eventStyle = {
...style
};
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
if (currentView !== 'agenda') {
//if event.type is availability show in blue. if it is schedule - green if confirmed, yellow if not confirmed
//if event is not active - show in gray
let bgColorClass = 'bg-gray-500'; // Default color for inactive events
var bgColor = event.isactive ? "" : "bg-gray-500";
if (event.type === "assignment") {
bgColor = event.isConfirmed ? "bg-green-500" : "bg-yellow-500";
//event.title = event.publisher.name; //ToDo: add other publishers names
//event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
} else {
if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) {
try {
if (event.type === "recurring") {
//bgColor = "bg-blue-300";
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
}
else {
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
}
}
catch (err) {
event.title = event.startTime + " - " + event.endTime;
console.log("Error in EventWrapper: " + err);
}
}
}
eventStyle = {
...style,
// backgroundColor: bgColorClass,
//height: "50px",
//color: 'white',
whiteSpace: 'normal', // Allow the text to wrap to the next line
overflow: 'hidden', // Hide overflowed content
textOverflow: 'ellipsis' // Add ellipsis to text that's too long to fit
};
}
const onDelete = (event) => {
// Remove the event from the calendar
setEvents(currentEvents => currentEvents.filter(e => e.id !== event.id));
};
const onConfirm = (event) => {
console.log("onConfirm: " + event.id);
toast.info("Вие потвърдихте!", { autoClose: 2000 });
// Update the event data
event.isConfirmed = true;
event.isactive = false;
// Update the events array by first removing the old event and then adding the updated one
setEvents(currentEvents => {
const filteredEvents = currentEvents.filter(e => e.id !== event.id);
return [...filteredEvents, event];
});
};
return (
<div style={eventStyle} className={bgColor + " relative"}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} >
{event.title}
{isHovered && event.type == "assignment" && (event.status == "pending" || event.status == undefined)
&& (
<div className="absolute top-1 left-0 right-0 flex justify-between px-1">
{/* Delete Icon */}
{/* <span
className="disabled cursor-pointer rounded-full bg-red-500 text-white flex items-center justify-center"
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
onClick={() => onDelete(event)}
>
</span> */}
{/* Confirm Icon */}
{!event.isConfirmed && (
<span
className=" cursor-pointer rounded-full bg-green-500 text-white flex items-center justify-center"
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
onClick={() => onConfirm(event)}
>
</span>
)}
</div>
)}
</div>
);
};
const eventStyleGetter = (event, start, end, isSelected) => {
//console.log("eventStyleGetter: " + event);
let backgroundColor = '#3174ad'; // default color for calendar events - #3174ad
if (currentView === 'agenda') {
return { style: {} }
}
if (event.type === "assignment") {
//event.title = event.publisher.name; //ToDo: add other publishers names
}
if (event.type === "availability") {
}
if (event.isFromPreviousAssignment) { //ToDo: does it work?
// orange-500 from Tailwind CSS
backgroundColor = '#f56565';
}
if (event.isactive) {
switch (event.type) {
case 'assignment':
backgroundColor = event.isConfirmed ? '#48bb78' : '#f6e05e'; // green-500 and yellow-300 from Tailwind CSS
break;
case 'recurring':
backgroundColor = '#63b3ed'; // blue-300 from Tailwind CSS
break;
default: // availability
//backgroundColor = '#a0aec0'; // gray-400 from Tailwind CSS
break;
}
} else {
backgroundColor = '#a0aec0'; // Default color for inactive events
}
return {
style: {
backgroundColor,
opacity: 0.8,
color: 'white',
border: '0px',
display: 'block',
}
};
}
// Custom Toolbar Component
const CustomToolbar = ({ onNavigate, label, onView, view }) => {
return (
<div className="rbc-toolbar">
<span className="rbc-btn-group">
<button type="button" onClick={() => onNavigate('PREV')}>
<FaArrowLeft className="icon-large" />
</button>
<button type="button" onClick={() => onNavigate('TODAY')}>
<MdToday className="icon-large" />
</button>
<button type="button" onClick={() => onNavigate('NEXT')}>
<FaArrowRight className="icon-large" />
</button>
</span>
<span className="rbc-toolbar-label">{label}</span>
<span className="rbc-btn-group">
<button type="button" onClick={() => onView('month')} className={view === 'month' ? 'rbc-active' : ''}>
<FaRegCalendarAlt className="icon-large" />
</button>
<button type="button" onClick={() => onView('week')} className={view === 'week' ? 'rbc-active' : ''}>
<FaRegListAlt className="icon-large" />
</button>
<button type="button" onClick={() => onView('agenda')} className={view === 'agenda' ? 'rbc-active' : ''}>
<FaRegCalendarCheck className="icon-large" />
</button>
{/* Add more view buttons as needed */}
</span>
</div>
);
};
return (
<> <div {...handlers} className="flex flex-col"
>
{/* достъпности на {publisherId} */}
<ToastContainer position="top-center" style={{ zIndex: 9999 }} />
</div>
<Calendar
localizer={localizer}
events={displayedEvents}
startAccessor="startTime"
endAccessor="endTime"
selectable={true}
onSelectSlot={handleSelect}
onSelectEvent={handleEventClick}
style={{ height: '100%', width: '100%' }}
min={minTime} // Set minimum time
max={maxTime} // Set maximum time
messages={messages}
view={currentView}
views={['month', 'week', 'agenda']}
onView={view => setCurrentView(view)}
onRangeChange={onRangeChange}
components={{
event: EventWrapper,
toolbar: CustomToolbar,
// ... other custom components
}}
eventPropGetter={(eventStyleGetter)}
date={date}
onNavigate={setDate}
className="rounded-lg shadow-lg"
/>
{isModalOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="modal-content">
<AvailabilityForm
publisherId={publisherId}
existingItem={selectedEvent}
onDone={handleDialogClose}
inline={true}
// Pass other props as needed
/>
</div>
<div className="fixed inset-0 bg-black opacity-50" onClick={handleCancel}></div>
</div>
)}
</>
);
};
export default AvCalendar;

View File

@ -0,0 +1,196 @@
import { CartEvent } from '@prisma/client';
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import axiosInstance from '../../src/axiosSecure';
import DayOfWeek from "../DayOfWeek";
import common from 'src/helpers/common';
/*
model CartEvent {
id Int @id @default(autoincrement())
startTime DateTime
endTime DateTime
shiftDuration Int
shifts Shift[]
dayofweek DayOfWeek
isactive Boolean @default(true)
}*/
interface Location {
id: number;
name: string;
}
interface IProps {
item?: CartEvent;
locations: Location[];
inline?: false;
}
export default function CartEventForm(props: IProps) {
const router = useRouter();
console.log("init CartEventForm: " + JSON.stringify(props));
const urls = {
apiUrl: "/api/data/cartevents/",
indexUrl: "/cart/cartevents"
}
const [evt, setEvt] = useState(props.item || {
id: router.query.id,
startTime: "09:00",//8:00
endTime: "19:30",//20:00
shiftDuration: 90,//120
dayofweek: "Monday",
});
//const locations = props?.locations || [];
//get locations from api
const [locations, setLocations] = useState(props?.locations || []);
useEffect(() => {
const fetchLocations = async () => {
try {
console.log("fetching locations");
const { data } = await axiosInstance.get("/api/data/locations");
setLocations(data);
console.log(data);
} catch (error) {
console.error(error);
}
};
if (!locations.length) {
fetchLocations();
}
}, []);
useEffect(() => {
const fetch = async (id) => {
try {
console.log("fetching cart event from component " + router.query.id);
const { data } = await axiosInstance.get(urls.apiUrl + id);
data.startTime = common.formatTimeHHmm(data.startTime)
data.endTime = common.formatTimeHHmm(data.endTime)
setEvt(data);
console.log("id:" + evt.id);
//console.log(data);
} catch (error) {
console.error(error);
}
};
if (!props?.item) {
setEvt(prevEvt => ({ ...prevEvt, id: router.query.id as string }));
}
if (evt.id) {
fetch(parseInt(evt.id));
}
}, [router.query.id]);
const handleChange = ({ target }) => {
console.log("CartEventForm.handleChange() " + target.name + " = " + target.value);
if (target.type === "checkbox") {
setEvt({ ...evt, [target.name]: target.checked });
} else if (target.type === "number") {
console.log("setting " + target.name + " to " + parseInt(target.value));
setEvt({ ...evt, [target.name]: parseInt(target.value) });
} else {
setEvt({ ...evt, [target.name]: target.value });
}
console.log("CartEventForm.handleChange() " + JSON.stringify(evt));
}
const handleSubmit = async (e) => {
e.preventDefault();
try {
const eventId = evt.id;
delete evt.id;
evt.startTime = common.parseTimeHHmm(evt.startTime.toString());
evt.endTime = common.parseTimeHHmm(evt.endTime.toString());
console.log("calling api @ " + urls.apiUrl + evt.id);
console.log(evt);
if (eventId) { //update
// evt.location = {
// id: evt.locationId
// };
delete evt.locationId;
await axiosInstance.put(urls.apiUrl + router.query.id, {
...evt,
});
toast.success("Task Updated", {
position: "bottom-center",
});
} else {
evt.locationId = parseInt(evt.locationId?.toString());
await axiosInstance.post(urls.apiUrl, evt);
toast.success("Task Saved", {
position: "bottom-center",
});
}
// if (props.inline) {
// } else {
router.push(urls.indexUrl);
// }
} catch (error) {
// toast.error(error.response.data.message);
console.error(error);
}
};
return (
<div className="w-full max-w-xs mt-5 mx-auto shadow-md shadow-gray-500">
<h3 className='p-5 pb-0 text-center text-lg '>{evt.id ? "Edit '" + evt.dayofweek + "'" : "Create"} Cart Event</h3>
<form className="form mb-0" onSubmit={handleSubmit}>
<label className='label' htmlFor="location">Location</label>
{locations && (
<select name="locationId" id="locationId" onChange={handleChange} value={evt.locationId}>
{locations.map((loc: Location) => (
<option key={loc.id} value={loc.id} type="number">{loc.name}</option>
))}
</select>
)}
<label className='label' htmlFor="dayofweek">Day of Week</label>
<DayOfWeek onChange={handleChange} selected={evt.dayofweek} />
<label className='label' htmlFor="startTime">Start Time</label>
<input className="shadow border form-input" type="time" name="startTime" id="startTime" onChange={handleChange} value={evt.startTime} />
<label className='label' htmlFor="endTime">End Time</label>
<input className="shadow border form-input" type="time" name="endTime" id="endTime" onChange={handleChange} value={evt.endTime} />
<label className='label' htmlFor="shiftDuration">Shift Duration</label>
<input className="shadow border form-input" type="number" name="shiftDuration" id="shiftDuration" onChange={handleChange} value={evt.shiftDuration} />
<label className='label' htmlFor="numberOfPublishers">Max Shifts</label>
<input className="shadow border form-input" type="number" name="numberOfPublishers" id="numberOfPublishers" onChange={handleChange} value={evt.numberOfPublishers} />
<div className="mb-4">
<div className="form-check">
<input className="checkbox" type="checkbox" name="isactive" id="isactive" checked={evt.isactive} onChange={handleChange} />
<label className='label align-text-bottom' htmlFor="isactive">Active</label>
</div>
</div>
<div className="panel-actions">
{!props?.inline && <Link href={urls.indexUrl} className="action-button"> Cancel </Link>}
{evt.id &&
<button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={async () => {
await axiosInstance.delete(urls.apiUrl + router.query.id);
router.push(urls.indexUrl);
}}>
Delete
</button>}
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit"> {evt.id ? "Update" : "Create"}</button>
</div>
</form >
</div >
)
}

29
components/footer.tsx Normal file
View File

@ -0,0 +1,29 @@
import Link from "next/link"
import styles from "../styles/footer.module.css"
import packageJSON from "../package.json"
export default function Footer() {
return (
<footer className="pt-10 bg-gray-100 text-gray-600">
<hr />
<ul className={styles.navItems}>
<li className={styles.navItem}>
<a href="https://next-auth.js.org">Documentation</a>
</li>
<li className={styles.navItem}>
<a href="https://www.npmjs.com/package/next-auth">NPM</a>
</li>
<li className={styles.navItem}>
<a href="https://github.com/nextauthjs/next-auth-example">GitHub</a>
</li>
<li className={styles.navItem}>
<Link href="/policy">Policy</Link>
</li>
<li className={styles.navItem}>
<em>next-auth@{packageJSON.dependencies["next-auth"]}</em>
</li>
</ul>
</footer>
)
}

124
components/header.tsx Normal file
View File

@ -0,0 +1,124 @@
import Link from "next/link"
import { signIn, signOut, useSession } from "next-auth/react"
import styles from "../styles/header.module.css"
// The approach used in this component shows how to build a sign in and sign out
// component that works on pages which support both client and server side
// rendering, and avoids any flash incorrect content on initial page load.
export default function Header() {
const { data: session, status } = useSession()
const loading = status === "loading"
//generate top header with sign in/out button and dropdown menu and user name/surname using tailwindcss
return (
<header className="">
<noscript>
<style>{`.nojs-show { opacity: 1; top: 0; }`}</style>
</noscript>
{/* <script src="https://cdn.jsdelivr.net/npm/tw-elements/dist/js/index.min.js"></script> */}
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${
!session && loading ? styles.loading : styles.loaded
}`}
>
{!session && (
<>
<span className={styles.notSignedInText}>
You are not signed in
</span>
<a
href={`/api/auth/signin`}
className={styles.buttonPrimary}
onClick={(e) => {
e.preventDefault()
signIn()
}}
>
Sign in
</a>
</>
)}
{session?.user && (
<>
{session.user.image && (
<span
style={{ backgroundImage: `url('${session.user.image}')` }}
className={styles.avatar}
/>
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email ?? session.user.name}</strong>
</span>
<a
href={`/api/auth/signout`}
className={styles.button}
onClick={(e) => {
e.preventDefault()
signOut()
}}
>
Sign out
</a>
</>
)}
</p>
</div>
<nav className="max-w-7xl mx-auto ">
<ul className={styles.navItems}>
<li className={styles.navItem}>
<Link href="/">Home</Link>
</li>
<li className={styles.navItem}>
<Link href="/client">Client</Link>
</li>
<li className={styles.navItem}>
<Link href="/server">Server</Link>
</li>
<li className={styles.navItem}>
<Link href="/protected">Protected</Link>
</li>
<li className={styles.navItem}>
<Link href="/api-example">API</Link>
</li>
<li className={styles.navItem}>
<Link href="/admin">Admin</Link>
</li>
<li className={styles.navItem}>
<Link href="/me">Me</Link>
</li>
<li className={styles.navItem}>
CART
{/* cart submenus */}
<ul className={styles.submenu}>
<li className={styles.submenuItem}>
<Link href="/cart/locations">Locations</Link>
</li>
<li className={styles.submenuItem}>
<Link href="/cart/publishers">Publishers</Link>
</li>
<li className={styles.submenuItem}>
<Link href="/cart/availabilities">Availabilities</Link>
</li>
<li className={styles.submenuItem}>
<Link href="/cart/cartevents">Cart Event</Link>
</li>
</ul>
</li>
{/* end cart submenus */}
<li className={styles.navItem}>
<Link href="/cart/calendar">Calendar</Link>
</li>
</ul>
</nav>
</header>
)
}

64
components/layout.tsx Normal file
View File

@ -0,0 +1,64 @@
import Header from "./header"
import Link from 'next/link'
import Footer from "./footer"
import Sidebar from "./sidebar"
import type { ReactNode } from "react"
import { useRouter } from 'next/router'
import { useEffect, useState } from "react";
import Body from 'next/document'
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
export default function Layout({ children }: { children: ReactNode }) {
const router = useRouter()
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
// auto resize for tablets: disabled.
// useEffect(() => {
// // Function to check and set the state based on window width
// const handleResize = () => {
// if (window.innerWidth < 768) { // Assuming 768px as the breakpoint for mobile devices
// setIsSidebarOpen(false);
// } else {
// setIsSidebarOpen(true);
// }
// };
// // Set initial state
// handleResize();
// // Add event listener
// window.addEventListener('resize', handleResize);
// // Cleanup
// return () => window.removeEventListener('resize', handleResize);
// }, []);
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
return (
// <div className="h-screen w-screen" >
// <div className="flex flex-col">
// <div className="flex flex-row h-screen">
// <ToastContainer />
// <Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
// <main className={`pr-10 transition-all duration-300 ${isSidebarOpen ? 'ml-64 w-[calc(100%-16rem)] ' : 'ml-0 w-[calc(100%)] '}`}>
<div className="">
<div className="flex flex-col">
<div className="flex flex-row h-[90vh] w-screen ">
<ToastContainer position="top-center" style={{ zIndex: 9999 }} />
<Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
<main className={`pr-10 transition-all h-[90vh] duration-300 ${isSidebarOpen ? 'ml-64 w-[calc(100%-16rem)] ' : 'ml-0 w-[calc(100%)] '}`}>
{children}
</main>
</div>
{/* <div className="justify-end items-center text-center ">
<Footer />
</div> */}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { useState } from 'react'
import { useRouter } from "next/router";
import axiosInstance from '../../src/axiosSecure';
import { TrashIcon } from "@heroicons/react/24/outline";
export default function LocationCard({ location }) {
const router = useRouter();
const [isCardVisible, setIsCardVisible] = useState(true);
const handleDelete = async (id) => {
try {
console.log("card: deleting location = ", id, "url: ", `/locations/${id}`);
const response = await axiosInstance.delete(`/api/data/locations/${id}`);
if (response.status === 200) {
document.getElementById(`location-card-${id}`).classList.add('cardFadeOut');
setTimeout(() => setIsCardVisible(false), 300);
}
} catch (error) {
console.log(JSON.stringify(error));
}
};
return isCardVisible ? (
<>
<div
id={`location-card-${location.id}`}
className={`relative block p-6 max-w-sm rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3 cursor-pointer ${location.isactive ? 'text-gray-900 dark:text-white font-bold' : 'text-gray-400 dark:text-gray-600'}`}
onClick={() => router.push(`/cart/locations/edit/${location.id}`)}
>
<h5 className={`mb-2 text-2xl tracking-tight`}>
{location.name} ({location.isactive ? "active" : "inactive"})
</h5>
<p className="font-normal text-gray-700 dark:text-gray-200">
{location.address}
</p>
<div
onClick={(e) => {
e.stopPropagation(); // This should now work as expected
handleDelete(location.id);
}}
className="absolute bottom-2 right-2 z-20"
>
<button
aria-label="Delete location"
className="text-red-600 bg-transparent hover:bg-red-100 p-1 hover:border-red-700 rounded"
>
<TrashIcon className="h-6 w-6" />
</button>
</div>
</div>
</>
) : null;
}

View File

@ -0,0 +1,272 @@
import axiosInstance from '../../src/axiosSecure';
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Link from "next/link";
import DayOfWeek from "../DayOfWeek";
import TextEditor from "../TextEditor";
import FileUploadWithPreview from 'components/FileUploadWithPreview ';
import ProtectedRoute, { serverSideAuth } from "../..//components/protectedRoute";
import { UserRole } from "@prisma/client";
const common = require('src/helpers/common');
// ------------------ LocationForm ------------------
// This component is used to create and edit locations
// location model:
// model Location {
// id Int @id @default(autoincrement())
// name String
// address String
// isactive Boolean @default(true)
// content String? @db.Text
// cartEvents CartEvent[]
// reports Report[]
// backupLocationId Int?
// backupLocation Location? @relation("BackupLocation", fields: [backupLocationId], references: [id])
// BackupForLocations Location[] @relation("BackupLocation")
// }
export default function LocationForm() {
const [uploadedImages, setUploadedImages] = useState([]);
const [isPreviewMode, setIsPreviewMode] = useState(false);
useEffect(() => {
const fetchUploadedImages = async () => {
try {
const response = await axiosInstance.get('/uploaded-images');
setUploadedImages(response.data.imageUrls);
} catch (error) {
console.error('Error fetching uploaded images:', error);
}
};
fetchUploadedImages();
}, []);
const quillRef = useRef(null);
const handleImageSelect = (e) => {
const imageUrl = e.target.value;
if (imageUrl && quillRef.current) {
const editor = quillRef.getQuill();
const range = editor.getSelection(true);
if (range) {
editor.insertEmbed(range.index, 'image', imageUrl);
}
}
};
const [content, setContent] = useState("");
const [location, set] = useState({
name: "",
address: "",
isactive: true,
});
// const [isEdit, setIsEdit] = useState(false);
const router = useRouter();
useEffect(() => {
const fetchLocation = async (id) => {
try {
console.log("fetching location " + router.query.id);
const { data } = await axiosInstance.get("/api/data/locations/" + id);
set(data);
setContent(data.content);
} catch (error) {
console.error(error);
}
};
if (router.query?.id) {
fetchLocation(parseInt(router.query.id.toString()));
}
console.log("called");
}, [router.query.id]);
const [locations, setLocations] = useState([]);
useEffect(() => {
const fetchLocations = async () => {
try {
console.log("fetching locations");
const { data } = await axiosInstance.get("/api/data/locations");
setLocations(data);
console.log(data);
} catch (error) {
console.error(error);
}
};
if (!locations.length) {
fetchLocations();
}
}, []);
const handleChange = ({ target }) => {
if (target.type === "checkbox") {
set({ ...location, [target.name]: target.checked });
} else if (target.type === "number") {
set({ ...location, [target.name]: parseInt(target.value) });
} else {
set({ ...location, [target.name]: target.value });
}
}
const handleSubmit = async (e) => {
e.preventDefault();
try {
const dataToSend = {
...location,
name: location.name.trim(),
content: content,
};
if (router.query?.id) { // UPDATE
//connect backup location
delete dataToSend.id;
dataToSend.backupLocationId = parseInt(dataToSend.backupLocationId);
// dataToSend.backupLocation = { connect: { id: location.backupLocationId } };
// delete dataToSend.backupLocationId;
await axiosInstance.put("/api/data/locations/" + router.query.id, {
...dataToSend,
});
toast.success("Task Updated", {
position: "bottom-center",
});
} else { // CREATE
await axiosInstance.post("/api/data/locations", dataToSend);
toast.success("Task Saved", {
position: "bottom-center",
});
}
router.push("/cart/locations");
} catch (error) {
//toast.error(error.response.data.message);
}
};
return (
<>
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<div className="w-full max-lg">
<form className="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
<div className="mb-4">
<label className="label" htmlFor="name">Location Name</label>
<input className="textbox" placeholder="name" id="name" name="name" onChange={handleChange} value={location.name} autoComplete="off" />
</div>
{/* Location.address */}
<div className="mb-4">
<label className="label" htmlFor="address"> Address</label>
<input className="textbox"
placeholder="address" id="address" name="address" onChange={handleChange} value={location.address} autoComplete="off" />
</div>
{/* UI for Location.isactive */}
<div className="mb-4">
<div className="form-check">
<input className="checkbox form-input" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={location.isactive} autoComplete="off" />
<label className="label" htmlFor="isactive">Активна</label>
</div>
</div>
{/* backupLocation */}
<div className="mb-4">
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
{locations && (
<select name="backupLocationId" id="backupLocationId" onChange={handleChange} value={location.backupLocationId} placeholder="Избери локация...">
<option>Избери локация...</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id} type="number">{loc.name}</option>
))}
</select>
)}
</div>
{/* Location.content */}
<div className="mb-4">
{/* <select onChange={handleImageSelect}>
<option>Select an image</option>
{uploadedImages.map((imageUrl, index) => (
<option key={index} value={imageUrl}>{imageUrl}</option>
))}
</select> */}
{router.query.id && (
<>
<div className="flex space-x-4">
<FileUploadWithPreview
name="picture1"
label="Снимка 1"
value={location.picture1}
prefix={`location-${router.query.id}-picture1`}
onUpload={(name, imageUrl) => {
console.log('Uploaded image URL:', imageUrl);
set(location => ({ ...location, [name]: imageUrl }));
}}
/>
<FileUploadWithPreview
name="picture2"
label="Снимка 2"
value={location.picture2}
prefix={`location-${router.query.id}-picture2`}
onUpload={(name, imageUrl) => {
console.log('Uploaded image URL:', imageUrl);
set(location => ({ ...location, [name]: imageUrl }));
}}
/>
<FileUploadWithPreview
name="picture3"
label="Снимка 3"
value={location.picture3}
prefix={`location-${router.query.id}-picture3`}
onUpload={(name, imageUrl) => {
console.log('Uploaded image URL:', imageUrl);
set(location => ({ ...location, [name]: imageUrl }));
}}
/>
</div>
<label className="label" htmlFor="content">Content</label>
<TextEditor
ref={quillRef}
value={content}
onChange={setContent}
placeholder="Описание на локацията. Снимки"
prefix={`location-${router.query.id}`} />
</>)}
</div>
<div className="panel-actions pt-12">
<Link href="/cart/locations" className="action-button"> обратно </Link>
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
{router.query?.id ? "Update" : "Save"}
</button>
</div>
</form>
</div>
<button className='button' onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? 'Скрий потребителския изглед' : 'Виж какво ще се покаже на потребителите'}
</button>
</ProtectedRoute>
<ProtectedRoute allowedRoles={[UserRole.USER]} deniedMessage=" " bypass={isPreviewMode}>
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
{location.name}
{location.address}
{location.backupLocationName}
<div className="border-2 border-blue-500 p-5 my-5 rounded-lg" dangerouslySetInnerHTML={{ __html: content }}></div>
</ProtectedRoute>
</>
);
}

View File

@ -0,0 +1,55 @@
export default function PrivacyPolicyBG() {
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Политика за Поверителност</h1>
<p className="mb-4">
Тази политика за поверителност очертава как ние събираме, използваме и защитаваме вашите лични данни в съответствие с Общия регламент за защита на данните (GDPR).
</p>
<h2 className="text-xl font-semibold mb-2">Информация, която Събираме</h2>
<p className="mb-4">
Ние събираме само лични данни, които вие доброволно ни предоставяте, като вашето име, електронна поща, телефонен номер и др.. Събраната лична информация се използва за предоставяне, поддържане и подобряване на нашите услуги, управление на потребителски акаунти и комуникация с вас относно услуги или продукти.
</p>
<h2 className="text-xl font-semibold mb-2">Как Използваме Информацията</h2>
<p className="mb-4">
Ние използваме вашите лични данни за предоставяне на услуги и подобряване на вашето удобство при използване на нашия сайт. Ние не продаваме или споделяме вашите лични данни с трети страни, освен ако не е необходимо по закон. Ние се ангажираме да никога не предоставяме личните данни, които държим, на трети страни.
</p>
<h2 className="text-xl font-semibold mb-2">Защита на Данните и Сигурност</h2>
<p className="mb-4">
Ние прилагаме различни мерки за сигурност за поддържане на безопасността на вашата лична информация, включително HTTPS и криптиране на данни. Достъпът до вашите лични данни е ограничен само за упълномощени лица.
</p>
<h2 className="text-xl font-semibold mb-2">Вашите Права и Решения</h2>
<p className="mb-4">
Съгласно GDPR, имате право да достъпите, актуализирате или изтриете информацията, която имаме за вас. Имате също така права за коригиране, възражение, ограничаване и пренасяне на данни. Можете по всяко време да се откажете от комуникацията с нас и да имате право да оттеглите съгласието си.
</p>
<p className="mb-4">
За да упражните тези права, моля свържете се с нас на [EMAIL].
</p>
<h2 className="text-xl font-semibold mb-2">Промени в тази Политика</h2>
<p className="mb-4">
Ние можем от време на време да актуализираме нашата Политика за Поверителност. Ще ви уведомим за всякакви промени, като публикуваме новата Политика за Поверителност на тази страница и актуализираме датата на "Последно актуализирана".
</p>
<p className="mb-4">
Последно актуализирана: 03.02.2024 г.
</p>
<h2 className="text-xl font-semibold mb-2">Свържете се с Нас</h2>
<p className="mb-4">
Ако имате въпроси относно нашите практики за поверителност или тази Политика за Поверителност, моля свържете се с нас на [EMAIL].
</p>
</div>
)
}

View File

@ -0,0 +1,27 @@
import React, { useState } from 'react';
import PrivacyPolicyEN from './PrivacyPolicyEN';
import PrivacyPolicyBG from './PrivacyPolicyBG';
export default function PrivacyPolicyContainer() {
const [language, setLanguage] = useState('bg'); // default language is Bulgarian
const toggleLanguage = () => {
setLanguage(language === 'en' ? 'bg' : 'en');
};
return (
<div className="bg-white shadow-lg rounded-lg overflow-hidden">
<div className="px-4 py-5 sm:px-6">
<button
onClick={toggleLanguage}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
{language === 'en' ? 'На български' : 'In English'}
</button>
</div>
<div className="border-t border-gray-200">
{language === 'en' ? <PrivacyPolicyEN /> : <PrivacyPolicyBG />}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
export default function PrivacyPolicyEN() {
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Privacy Policy</h1>
<p className="mb-4">
This privacy policy outlines how we collect, use, and protect your personal data in accordance with the General Data Protection Regulation (GDPR).
</p>
<h2 className="text-xl font-semibold mb-2">Information We Collect</h2>
<p className="mb-4">
We only collect personal data that you voluntarily provide to us, such as your name, email address, phone number, etc.. The personal information we collect is used to provide, maintain, and improve our services, manage user accounts, and communicate with you about services or products.
</p>
<h2 className="text-xl font-semibold mb-2">How We Use Information</h2>
<p className="mb-4">
We use your personal data to provide services and improve your experience on our site. We do not sell or share your personal data with third parties, except when required by law.
</p>
<h2 className="text-xl font-semibold mb-2">Data Protection and Security</h2>
<p className="mb-4">
We implement a variety of security measures to maintain the safety of your personal information, including HTTPS and data encryption. Access to your personal data is limited to authorized staff only.
</p>
<h2 className="text-xl font-semibold mb-2">Your Rights and Choices</h2>
<p className="mb-4">
Under GDPR, you have the right to access, update, or delete the information we have on you. You also have the rights of rectification, objection, restriction, and data portability. You can opt-out of communications from us at any time and have the right to withdraw consent.
</p>
<p className="mb-4">
To exercise these rights, please contact us at [EMAIL].
</p>
<h2 className="text-xl font-semibold mb-2">Changes to this Policy</h2>
<p className="mb-4">
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.
</p>
<p className="mb-4">
Last updated: 03.02.2024
</p>
<h2 className="text-xl font-semibold mb-2">Contact Us</h2>
<p className="mb-4">
If you have any questions about our privacy practices or this Privacy Policy, please contact us at [EMAIL].
</p>
</div>
)
}

View File

@ -0,0 +1,70 @@
// components/ProtectedRoute.tsx
import { useSession, signIn } from "next-auth/react";
import { useEffect, ReactNode } from "react";
import { useRouter } from 'next/router';
import { UserRole } from '../Enums/UserRole';
import { getSession } from "next-auth/react";
interface ProtectedRouteProps {
children: ReactNode;
allowedRoles: UserRole[];
deniedMessage?: string;
bypass?: boolean;
}
const ProtectedRoute = ({ children, allowedRoles, deniedMessage, bypass = false }: ProtectedRouteProps) => {
const { data: session, status } = useSession()
const router = useRouter();
useEffect(() => {
console.log("session.role:" + session?.user?.role);
if (!status || status === "unauthenticated") {
// Redirect to the sign-in page
if (!bypass) {
signIn();
}
return null;
}
else {
console.log("session.role:" + session?.user?.role);
}
}, [session, status, router]);
if (status === "authenticated") {
const userRole = session.user.role as UserRole; // Assuming role is part of the session object
// Grant access if allowedRoles is not defined, or if the user's role is among the allowed roles
if (bypass || !allowedRoles || (allowedRoles && allowedRoles.includes(userRole))) {
return <>{children}</>;
}
// Handle denied access
if (deniedMessage !== undefined) {
return <div>{deniedMessage}</div>;
}
return <div>Нямате достъп до тази страница. Ако мислите, че това е грешка, моля, свържете се с администраторите</div>;
}
if (status === "loading") {
return <div>Зареждане...</div>;
}
if (!session) return <a href="/api/auth/signin">Защитено съдържание. Впишете се.. </a>
return children;
};
export default ProtectedRoute;
export async function serverSideAuth({ req, allowedRoles }) {
const session = await getSession({ req });
if (!session || (allowedRoles && !allowedRoles.includes(session.user.role))) {
// User is not authenticated or doesn't have the required role
return {
redirect: {
destination: '/api/auth/signin', // Redirect to the sign-in page
permanent: false,
},
};
}
// Return the session if the user is authenticated and has the required role
return { session };
}

View File

@ -0,0 +1,101 @@
import Link from "next/link";
import { Publisher } from "@prisma/client"
// import {IsDateXMonthsAgo} from "../../helpers/const"
import { useEffect, useState } from 'react'
import toast from "react-hot-toast";
import axiosInstance from '../../src/axiosSecure';
//add months to date. works with negative numbers and numbers > 12
export function addMonths(numOfMonths, date) {
var date = new Date(date);
var m, d = (date = new Date(+date)).getDate();
date.setMonth(date.getMonth() + numOfMonths, 1);
m = date.getMonth();
date.setDate(d);
if (date.getMonth() !== m) date.setDate(0);
return date;
}
//is date in range of months from and to
//usage:
//is date in last month: IsDateInXMonths(date, -1, 0)
//is date in current month: IsDateInXMonths(date, 0, 0)
//is date in next month: IsDateInXMonths(date, 0, 1)
export function IsDateInXMonths(date, monthsFrom, monthsTo) {
var date = new Date(date);
var dateYearMonth = new Date(date.getFullYear(), date.getMonth(), 1);
if (monthsFrom === undefined) monthsFrom = -100;
if (monthsTo === undefined) monthsTo = 100;
// var from = new Date(date.setMonth(dateYearMonth.getMonth()+monthsFrom));
// var to = new Date(date.setMonth(dateYearMonth.getMonth()+monthsTo));
var from = addMonths(monthsFrom, dateYearMonth);
var to = addMonths(monthsTo, dateYearMonth);
//is date between from and to
return date >= from && date <= to;
};
export default function PublisherCard({ publisher }) {
const [isCardVisible, setIsCardVisible] = useState(true);
const handleDelete = async (id) => {
try {
console.log("card: deleting publisher = ", id, "url: ", `/api/data/publishers/${id}`);
const response = await axiosInstance.delete(`/api/data/publishers/${id}`);
if (response.status === 200) {
document.getElementById(`publisher-card-${id}`).classList.add('cardFadeOut');
setTimeout(() => setIsCardVisible(false), 300);
}
} catch (error) {
console.log(JSON.stringify(error));
}
};
return isCardVisible ? (
// className="block p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3"
<div id={`publisher-card-${publisher.id}`} className={`relative block p-6 max-w-sm rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3
${publisher.isImported ? "bg-orange-50" : (publisher.isTrained ? "bg-white" : "bg-red-50")}`}>
<a
href={`/cart/publishers/edit/${publisher.id}`}
className=""
key={publisher.id}
>
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
{publisher.firstName} {publisher.lastName} ({publisher.isactive ? "active" : "inactive"})
</h5>
<div className="font-normal text-gray-700 dark:text-gray-200">
<p> {publisher.assignments.length} смени общо</p>
<p> достъпност: {publisher.availabilities?.length}</p>
{/* <p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, -1, 0)).length} last month</p>
<p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, 0, 0)).length} this month</p>
<p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, 0, 1)).length} next month</p> */}
</div>
</a>
<div className="absolute bottom-2 right-2">
<button onClick={() => handleDelete(publisher.id)} aria-label="Delete Publisher">
<svg className="w-5 h-6 text-red-500 hover:text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 11V17" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 11V17" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 7H20" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{/* <path d="M8 9a1 1 0 000 2h4a1 1 0 100-2H8z" />
<path fillRule="evenodd" d="M4.293 4.293A1 1 0 015.707 3.707L10 8l4.293-4.293a1 1 0 111.414 1.414L11.414 9l4.293 4.293a1 1 0 01-1.414 1.414L10 10.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 9 4.293 4.707a1 1 0 010-1.414z" clipRule="evenodd" /> */}
</svg>
</button>
</div>
<style jsx>{`
.cardFadeOut {
transition: opacity 0.3s ease, transform 0.3s ease;
opacity: 0;
transform: scale(0.8);
}
`}</style>
</div>
) : null;
}

View File

@ -0,0 +1,317 @@
// import axios from "axios";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
import axiosInstance from '../../src/axiosSecure';
//import { getDate } from "date-fns";
import { monthNamesBG, GetTimeFormat, GetDateFormat } from "../../src/helpers/const"
import PublisherSearchBox from './PublisherSearchBox';
import AvailabilityList from "../availability/AvailabilityList";
import ShiftsList from "../publisher/ShiftsList.tsx";
import common from "../../src/helpers/common";
import ProtectedRoute from '../../components/protectedRoute';
import { UserRole } from "@prisma/client";
// import { Tabs, List } from 'tw-elements'
// model Publisher {
// id String @id @default(cuid())
// firstName String
// lastName String
// email String @unique
// phone String?
// isactive Boolean @default(true)
// isImported Boolean @default(false)
// age Int?
// availabilities Availability[]
// assignments Assignment[]
// emailVerified DateTime?
// accounts Account[]
// sessions Session[]
// role UserRole @default(USER)
// desiredShiftsPerMonth Int @default(4)
// isMale Boolean @default(true)
// isNameForeign Boolean @default(false)
// familyHeadId String? // Optional familyHeadId for each family member
// familyHead Publisher? @relation("FamilyMember", fields: [familyHeadId], references: [id])
// familyMembers Publisher[] @relation("FamilyMember")
// type PublisherType @default(Publisher)
// Town String?
// Comments String?
// }
Array.prototype.groupBy = function (prop) {
return this.reduce(function (groups, item) {
const val = item[prop]
groups[val] = groups[val] || []
groups[val].push(item)
return groups
}, {})
}
export default function PublisherForm({ item, me }) {
const router = useRouter();
console.log("init PublisherForm: ");
const urls = {
apiUrl: "/api/data/publishers/",
indexUrl: "/cart/publishers"
}
const [helpers, setHelper] = useState(null);
const fetchModules = async () => {
const h = (await import("../../src/helpers/const.js")).default;
//console.log("fetchModules: " + JSON.stringify(h));
setHelper(h);
}
useEffect(() => {
fetchModules();
}, []);
const [publisher, set] = useState(item || {
isactive: true,
});
const handleChange = ({ target }) => {
if (target.type === "checkbox") {
set({ ...publisher, [target.name]: target.checked });
} else if (target.type === "number") {
set({ ...publisher, [target.name]: parseInt(target.value) });
} else {
set({ ...publisher, [target.name]: target.value });
}
if (item?.firstName) {
publisher.isMale = item.firstName && item.firstName.endsWith('а') ? false : true;
}
}
const handleParentSelection = (head) => {
//setSelectedParent(parent);
// Update the publisher state with the selected publisher's ID
console.log("handleParentSelection: " + JSON.stringify(head));
set({ ...publisher, familyHeadId: head.id });
// Create a new object excluding the familyHeadId property
};
const handleSubmit = async (e) => {
router.query.id = router.query.id || "";
console.log("handleSubmit: " + JSON.stringify(publisher));
console.log("urls.apiUrl + router.query.id: " + urls.apiUrl + router.query.id)
e.preventDefault();
//remove availabilities, assignments from publisher
publisher.availabilities = undefined;
publisher.assignments = undefined;
let { familyHeadId, userId, ...rest } = publisher;
// Set the familyHead relation based on the selected head
const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true };
const userRel = userId ? { connect: { id: userId } } : { disconnect: true };
// Return the new state without familyHeadId and with the correct familyHead relation
rest = {
...rest,
familyHead: familyHeadRelation,
user: userRel
};
try {
if (router.query?.id) {
await axiosInstance.put(urls.apiUrl + router.query.id, {
...rest,
});
toast.success("Task Updated", {
position: "bottom-center",
});
} else {
await axiosInstance.post(urls.apiUrl, publisher);
toast.success("Task Saved", {
position: "bottom-center",
});
}
router.push(urls.indexUrl);
} catch (error) {
console.log(JSON.stringify(error));
//toast.error(error.response.data.message);
}
};
const handleDelete = async (e) => {
e.preventDefault();
try {
//console.log("deleting publisher id = ", router.query.id, "; url=" + urls.apiUrl + router.query.id);
await axiosInstance.delete(urls.apiUrl + router.query.id);
toast.success("Записът изтрит", {
position: "bottom-center",
});
router.push(urls.indexUrl);
} catch (error) {
console.log(JSON.stringify(error));
toast.error(error.response.data.message);
}
};
let formTitle;
me = common.parseBool(me);
if (me) {
formTitle = "Моят профил / Настройки";
} else if (router.query?.id) {
formTitle = "Редактирай вестител"; // "Edit Publisher"
} else {
formTitle = "Създай вестител"; // "Create Publisher"
}
return (
<>
<div className="flex flex-col">
<h3 className="text-2xl font-semibold mt-6 mb-4">{formTitle}</h3>
<div className="h-4"></div>
<div className="flex flex-row">
<form className="form"
onSubmit={handleSubmit}>
<div className="mb-4">
<label className="label" htmlFor="firstName">Име</label>
<input type="text" name="firstName" value={publisher.firstName} onChange={handleChange} className="textbox" placeholder="First Name" autoFocus />
</div>
<div className="mb-4">
<label className="label" htmlFor="lastName">Фамилия</label>
<input type="text" name="lastName" value={publisher.lastName} onChange={handleChange} className="textbox" placeholder="Last Name" autoFocus />
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
<div className="form-check">
<input className="checkbox" type="checkbox" value={publisher.isNameForeign} id="isNameForeign" name="isNameForeign" onChange={handleChange} checked={publisher.isNameForeign} autoComplete="off" />
<label className="label" htmlFor="isNameForeign">
Чуждестранна фамилия</label>
</div>
</ProtectedRoute>
</div>
{/* //desiredShiftsPerMonth */}
<div className="mb-4">
<label className="label" htmlFor="desiredShiftsPerMonth">Желани смeни на месец</label>
<input type="number" name="desiredShiftsPerMonth" value={publisher.desiredShiftsPerMonth} onChange={handleChange} className="textbox" placeholder="desiredShiftsPerMonth" autoFocus />
</div>
<div className="mb-4">
<label className="label" htmlFor="email">Имейл</label>
<input type="text" name="email" value={publisher.email} onChange={handleChange} className="textbox" placeholder="Email" autoFocus />
</div>
<div className="mb-4">
<label className="label" htmlFor="phone">Телефон</label>
<input type="text" name="phone" value={publisher.phone} onChange={handleChange} className="textbox" placeholder="Phone" autoFocus />
</div>
<div className="mb-4">
<label className="label" htmlFor="parentPublisher">
Семейство (избери главата на семейството)
</label>
<PublisherSearchBox selectedId={publisher.familyHeadId} onChange={handleParentSelection} />
</div>
<div className="mb-4">
<div className="flex items-center space-x-4">
<div className="form-check">
<input className="radio" type="radio" id="male" name="isMale"
onChange={() => handleChange({ target: { name: "isMale", value: true } })}
checked={publisher.isMale}
/>
<label className="label" htmlFor="male">
Мъж
</label>
</div>
<div className="form-check">
<input className="radio" type="radio" id="female" name="isMale"
onChange={() => handleChange({ target: { name: "isMale", value: false } })}
checked={!publisher.isMale}
/>
<label className="label" htmlFor="female">
Жена
</label>
</div>
</div>
<div className="mb-4">
<label className="label" htmlFor="type">Тип</label>
<select name="type" value={publisher.type} onChange={handleChange} className="textbox" placeholder="Type" autoFocus >
<option value="Publisher">Вестител</option>
<option value="Bethelite">Бетелит</option>
<option value="RegularPioneer">Редовен Пионер</option>
<option value="SpecialPioneer">Специален Пионер/Мисионер</option>
{/* <option value="Missionary">Мисионер</option>
<option value="CircuitOverseer">Пътуваща служба</option> */}
</select>
</div>
<div className="mb-4">
<label className="label" htmlFor="town">Град</label>
<input type="text" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
</div>
<div className="mb-4">
<label className="label" htmlFor="comments">Коментари</label>
<input type="text" name="comments" value={publisher.comments} onChange={handleChange} className="textbox" placeholder="Коментари" autoFocus />
</div>
</div>
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
<div className="mb-4">
<label className="label" htmlFor="age">Възраст</label>
<input type="number" name="age" value={publisher.age} onChange={handleChange} className="textbox" placeholder="Age" autoFocus />
</div>
<div className="mb-4">
<div className="form-check">
<input className="checkbox" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={publisher.isactive} autoComplete="off" />
<label className="label" htmlFor="isactive">Активен</label>
<input className="checkbox" type="checkbox" id="isTrained" name="isTrained" onChange={handleChange} checked={publisher.isTrained} autoComplete="off" />
<label className="label" htmlFor="isTrained">Получил обучение</label>
<input className="checkbox disabled" type="checkbox" id="isImported" name="isImported" onChange={handleChange} checked={publisher.isImported} autoComplete="off" />
<label className="label " htmlFor="isImported">Импортиран от график</label>
</div>
</div>
<div className="mb-4">
<label className="label" htmlFor="role">Роля Потребител</label>
<select name="role" id="role" className="select" value={publisher.role} onChange={handleChange} >
{/* <option value='${UserRole.USER}'>Потребител</option> */}
<option value={`${UserRole.USER}`}>Потребител</option>
<option value={`${UserRole.EXTERNAL}`}>Външен</option>
<option value={`${UserRole.POWERUSER}`}>Организатор</option>
<option value={`${UserRole.ADMIN}`}>Администратор</option>
{/* Add other roles as needed */}
</select>
</div>
</ProtectedRoute>
{/* ---------------------------- Actions --------------------------------- */}
<div className="panel-actions">
<Link href={urls.indexUrl} className="action-button"> обратно </Link>
{/* delete */}
<button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={handleDelete}>
Delete
</button>
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
{router.query?.id ? "Update" : "Create"}
</button>
</div>
</form>
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex-col" id="shiftlist" >
<div className="">
<ShiftsList assignments={publisher.assignments} selectedtab={common.getCurrentYearMonth()} />
</div>
</div>
</div>
<div className="flex-1 p-5">
<AvailabilityList publisher={publisher} />
</div>
</div>
</div>
</div >
</>
)
};

View File

@ -0,0 +1,73 @@
import React, { useState, useEffect, useRef } from "react";
import axiosInstance from '../../src/axiosSecure';
import toast from "react-hot-toast";
import { useRouter } from "next/router";
const PublisherInlineForm = ({ publisherId, initialShiftsPerMonth }) => {
const [desiredShiftsPerMonth, setDesiredShiftsPerMonth] = useState(initialShiftsPerMonth);
const router = useRouter();
const storedValue = useRef(initialShiftsPerMonth);
useEffect(() => {
const fetchPublisherData = async () => {
if (publisherId != null) {
try {
const response = await axiosInstance.get(`/api/data/publishers/${publisherId}`);
const publisher = response.data;
setDesiredShiftsPerMonth(publisher.desiredShiftsPerMonth);
storedValue.current = publisher.desiredShiftsPerMonth;
} catch (error) {
console.error("Error fetching publisher data:", error);
toast.error("Не може да се зареди информация.");
}
}
};
//if (storedValue.current == null) {
fetchPublisherData();
//}
}, [publisherId]);
useEffect(() => {
const saveShiftsPerMonth = async () => {
if (publisherId && desiredShiftsPerMonth != null
&& initialShiftsPerMonth != desiredShiftsPerMonth
&& storedValue.current != desiredShiftsPerMonth) {
try {
await axiosInstance.put(`/api/data/publishers/${publisherId}`, {
desiredShiftsPerMonth,
});
toast.success("Смени на месец запазени", {
position: "bottom-center",
});
} catch (error) {
console.error("Error saving desired shifts per month:", error);
toast.error("Грешка при запазване на смени на месец");
}
}
};
saveShiftsPerMonth();
}, [desiredShiftsPerMonth]);
return (
<div className="flex flex-col sm:flex-row items-center space-y-2 sm:space-y-0 sm:space-x-2">
<label htmlFor="desiredShiftsPerMonth" className="block text-sm font-medium text-gray-700">
Желани смени на месец:
</label>
<input
type="number"
id="desiredShiftsPerMonth"
name="desiredShiftsPerMonth"
value={desiredShiftsPerMonth}
onChange={(e) => setDesiredShiftsPerMonth(parseInt(e.target.value))}
className="textbox mt-1 sm:mt-0 w-full sm:w-auto flex-grow"
placeholder="Желани смени на месец"
min="0" max="10"
/>
</div>
);
};
export default PublisherInlineForm;

View File

@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import axiosInstance from '../../src/axiosSecure';
import common from '../../src/helpers/common';
//import { is } from 'date-fns/locale';
function PublisherSearchBox({ selectedId, onChange, isFocused, filterDate, showSearch = true, showList = false, showAllAuto = false, infoText = " Семеен глава" }) {
const [selectedItem, setSelectedItem] = useState(null);
const [searchText, setSearchText] = useState('');
const [publishers, setPublishers] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [selectedDate, setSelectedDate] = useState(filterDate);
useEffect(() => {
fetchPublishers();
}, []); // Empty dependency array ensures this useEffect runs only once
const fetchPublishers = async () => {
console.log("fetchPublishers called");
try {
let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isactive&searchText=${searchText}&availabilities=false`;
if (filterDate) {
url += `&filterDate=${common.getISODateOnly(filterDate)}`;
}
if (showList) {
url += `&assignments=true`;
}
const { data: publishersData } = await axiosInstance.get(url);
//setPublishers(publishersData);
const activePublishers = publishersData.filter(publisher => publisher.isactive === true);
setPublishers(activePublishers);
} catch (error) {
// Handle errors
console.error("Error fetching publishers:", error);
}
};
const handleHeadSelection = (pub) => {
setSearchText('');
setSearchResults([]);
setSelectedItem(pub);
onChange(pub); // Pass the selected parent to the parent component
};
//allows us to trigger a focus on the input field when we trigger to show the search box from outside
const inputRef = React.useRef(null);
useEffect(() => {
console.log("isFocused changed = ", isFocused);
if (isFocused && inputRef.current) {
inputRef.current.focus();
}
}, [isFocused]);
// Update selectedDate filter from outside
// useEffect(() => {
// setSelectedDate(filterDate);
// console.log("filterDate changed = ", filterDate);
// }, [filterDate]);
// Update publishers when filterDate or showList changes
useEffect(() => {
fetchPublishers();
}, [filterDate, showList]);
// Update selectedItem when selectedId changes and also at the initial load
useEffect(() => {
if (publishers) {
const head = publishers.find((publisher) => publisher.id === selectedId);
if (head) {
//setSearchText(`${head.firstName} ${head.lastName}`);
setSelectedItem(head);
}
}
}, [selectedId, publishers]);
// Update searchResults when searchText or publishers change
useEffect(() => {
if (searchText || showAllAuto) {
const filteredResults = publishers.filter((publisher) => {
const fullName = `${publisher.firstName} ${publisher.lastName} `.toLowerCase();
return fullName.includes(searchText.trim().toLowerCase())
|| publisher.email.toLowerCase().includes(searchText.trim().toLowerCase());
});
setSearchResults(filteredResults);
} else {
setSearchResults([]);
}
}, [searchText, publishers]);
return (
<div className="relative">
{showSearch ? (
<>
<input ref={inputRef}
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onFocus={() => { isFocused = true; }}
className="textbox"
placeholder={`${selectedItem?.firstName || ""} ${selectedItem?.lastName || ""}`}
/>
{(showSearch) && (searchResults.length > 0 || showAllAuto) && (
<ul className="absolute bg-white border border-gray-300 w-full z-10">
{/* showAllAuto ? publishers : searchResults */}
{(searchResults).map((publisher) => (
<li key={publisher.id}
className={`p-2 cursor-pointer hover:bg-gray-200 ${publisher.assignmentsCurrentWeek > 0 ? 'text-orange-500' : ''}`}
onClick={() => { handleHeadSelection(publisher); }} >
{publisher.firstName} {publisher.lastName}
</li>
))}
</ul>
)}
{selectedItem && infoText && (
<p className="font-semibold pl-1">
{infoText}: {selectedItem.firstName} {selectedItem.lastName}
</p>
)}
</>
) : null}
{showList ? (
// Display only clickable list of all publishers
<ul className="absolute bg-white border border-gray-300 w-full z-10">
{publishers.map((publisher) => (
<li key={publisher.id}
className="p-2 cursor-pointer hover:bg-gray-200"
onClick={() => { handleHeadSelection(publisher); }} >
{publisher.firstName} {publisher.lastName}
</li>
))}
</ul>
) : null}
</div>
);
}
export default PublisherSearchBox;

View File

@ -0,0 +1,149 @@
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
import axiosInstance from '../../src/axiosSecure';
import { monthNamesBG, GetTimeFormat, GetDateFormat } from "../../src/helpers/const"
import common from "../../src/helpers/common";
type Assignment = {
items: {
[key: string]: any[];
};
keys: string[];
months: {
[key: string]: string;
};
};
type ShiftsListProps = {
assignments: Assignment;
selectedtab: string;
};
const ShiftsList = ({ assignments, selectedtab }: ShiftsListProps) => {
const { keys: assignmentKeys = [], months = [], items = [] } = assignments || {};
const [currentTab, setCurrentTab] = useState<string>(selectedtab || assignmentKeys[-1]);
console.log("assignments = ", assignments);
console.log("currentTab = ", currentTab);
const searchReplacement = async (id) => {
try {
var assignment = (await axiosInstance.get("/api/data/ssignments/" + id)).data;
assignment.isConfirmed = true;
// assignment.isDeleted = true;
await axiosInstance.put("/api/data/assignments/" + id, assignment);
toast.success("Shift Tentative", {
position: "bottom-center",
});
// router.push(urls.indexUrl);
} catch (error) {
console.log(JSON.stringify(error));
toast.error(error.response.data.message);
}
}
const AddToGoogleCalendar = async (id) => {
try {
const { url, event } = await axiosInstance.get(`/api/shiftgenerate?action=createcalendarevent&id=${id}`, {
headers: {
'Content-Type': 'application/json',
},
});
window.open(url, '_blank')
.addEventListener('load', function () {
console.log('loaded');
});
// fetchShifts();
// console.log(shifts);
res.writeHead(301, { "Location": url });
res.end();
} catch (error) {
console.log(error);
console.log(JSON.stringify(error));
if (error.response?.data?.message) {
toast.error(error.response.data.message);
}
}
}
return (
<div className="flex flex-col m-5 w-full">
<ul className="nav nav-tabs flex flex-col md:flex-row flex-wrap list-none border-b-0 pl-0 mb-1" role="tablist">
{assignmentKeys?.slice(-4).map(m => (
<li key={m.toString()} className="nav-item">
<a
href="#"
onClick={() => setCurrentTab(m)}
className={`text-blue-500 border-l border-t border-r inline-block py-2 px-4 ${currentTab === m ? 'active border-gray-300 font-bold' : ' border-transparent'}`}
// className={`nav-link block font-medium text-xs leading-tight uppercase border-x-0 border-t-0 border-b-2 ${currentTab === m ? "active border-blue-500 font-bold" : "border-transparent"} px-6 py-3 my-2 hover:border-transparent hover:bg-gray-100 focus:border-transparent`}
role="tab"
aria-controls={"tabs-" + m}
aria-selected={currentTab === m}
>
{months[m]}
</a>
</li>
))}
</ul>
<div className="tab-content flex flow w-full p-2 border-2 border-gray-300 rounded-md">
{assignmentKeys?.map(month => (
// <div className={`tab-pane fade ${month === currentTab ? "active show" : ""}`} key={month.toString()} role="tabpanel" aria-labelledby={"tabs-tab" + month}>
//if tab is selected
//common.getCurrentYearMonth(month)
currentTab === month ?
<div key={month} className={`tab-pane fade ${month === currentTab ? "active show" : ""}`} role="tabpanel" aria-labelledby={"tabs-tab" + month}>
<div className="flex items-center py-3 px-4">
<span className="text-xl font-medium">
{items[month]?.filter(Boolean).reduce((total, item) => (
Array.isArray(item) || typeof item === 'object' ? total + Object.keys(item).length : total
), 0)} смени за {months[month]}
</span>
</div>
{items[month]?.map((shiftDay, i) => (
shiftDay && shiftDay.length > 0 ? (
<div className="flex items-center space-x-2 py-1" key={i}>
<div className="font-bold flex-shrink-0 w-6 text-right">{i + ":"}</div> {/*This is the column for the date. I've given it a fixed width (w-8) which you can adjust*/}
<div className="flex-grow flex">
{shiftDay.map(assignment => (
<div className="flow space-x-2 bg-gray-200 rounded-lg shadow-md py-2 px-3" key={assignment.id}>
<span>{GetTimeFormat(assignment.start)} - {GetTimeFormat(assignment.end)}</span>
<button
className={`text-sm text-white font-semibold px-2 rounded-lg shadow ${assignment.isConfirmed ? "bg-yellow-400 hover:bg-yellow-500" : "bg-red-400 hover:bg-red-500"}`}
onClick={() => searchReplacement(assignment.id)}
>
Търси заместник
</button>
<button
className="text-sm bg-green-400 hover:bg-green-500 text-white font-semibold px-2 rounded-lg shadow"
onClick={() => AddToGoogleCalendar(assignment.id)}
>
Добави в календар
</button>
</div>
))}
</div>
</div>) : null
))}
</div>
: null
))}
</div>
</div >
);
}
export default ShiftsList;

View File

@ -0,0 +1,179 @@
import axiosInstance from '../../src/axiosSecure';
import { useEffect, useState } from "react";
import { toast } from 'react-toastify';
import { useRouter } from "next/router";
import Link from "next/link";
import DayOfWeek from "../DayOfWeek";
import { Location, UserRole } from "@prisma/client";
const common = require('src/helpers/common');
import { useSession } from "next-auth/react"
import dynamic from 'next/dynamic';
const ReactQuill = dynamic(() => import('react-quill'), {
ssr: false,
loading: () => <p>Loading...</p>,
});
import 'react-quill/dist/quill.snow.css'; // import styles
// ------------------ ExperienceForm ------------------
// This component is used to create and edit
// model:
// model Report {
// id Int @id @default(autoincrement())
// date DateTime
// publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
// publisherId String
// assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
// assignmentId Int
// placementCount Int?
// videoCount Int?
// returnVisitInfoCount Int?
// conversationCount Int?
// experienceInfo String?
// }
export default function ExperienceForm({ publisherId, assgnmentId, existingItem, onDone }) {
const { data: session, status } = useSession()
const [pubId, setPublisher] = useState(publisherId);
const router = useRouter();
//get the user from session if publisherId is null
useEffect(() => {
if (!publisherId) {
if (session) {
setPublisher(session.user.id);
}
}
}, [publisherId, session]);
const [item, setItem] = useState(existingItem || {
experienceInfo: "",
assignmentId: assgnmentId,
publisherId: publisherId,
date: new Date(),
placementCount: 0,
videoCount: 0,
returnVisitInfoCount: 0,
conversationCount: 0
});
const [locations, setLocations] = useState([]);
useEffect(() => {
const fetchLocations = async () => {
try {
console.log("fetching locations");
const { data } = await axiosInstance.get("/api/data/locations");
setLocations(data);
item.locationId = data[0].id;
console.log(data);
} catch (error) {
console.error(error);
}
};
if (!locations.length) {
fetchLocations();
}
}, []);
const handleLocationChange = ({ target }) => {
setItem({ ...item, [target.name]: target.value });
};
const handleChange = (content, delta, source, editor) => {
item.experienceInfo = content;
setItem(item);
console.log(editor.getHTML()); // rich text
};
const handleSubmit = async (e) => {
e.preventDefault();
item.publisher = { connect: { id: pubId } };
item.location = { connect: { id: parseInt(item.locationId) } };
delete item.locationId;
try {
const response = await axiosInstance.post('/api/data/reports', item);
console.log(response);
toast.success("Случката е записана. Благодарим Ви!");
setTimeout(() => {
if (onDone) {
onDone();
} else {
router.push(`/dash`);
}
}, 3000); // Delay for 3 seconds
} catch (error) {
console.log(error);
toast.error("Error saving report");
}
}
const modules = {
toolbar: {
container: [
['bold', 'italic', 'underline'], // Basic text formats
[{ 'list': 'ordered' }, { 'list': 'bullet' }], // Lists
['link', 'image'] // Media
],
}
};
return (
<div className="w-full max-w-md mx-auto">
<form className="bg-white dark:bg-gray-800 shadow rounded-lg px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
<div className="mb-4">
<label className='block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2' htmlFor="location">Място</label>
{locations && (
<select
name="locationId"
id="locationId"
value={item.locationId}
onChange={handleLocationChange}
className="block appearance-none w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded py-2 px-3 text-gray-700 dark:text-gray-300 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
))}
</select>
)}
</div>
<div className="mb-8"> {/* Increased bottom margin */}
<label className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2" htmlFor="experienceInfo">
Насърчителна случка
</label>
<ReactQuill
theme="snow"
value={item.experienceInfo}
onChange={handleChange}
modules={modules}
className="w-full h-60 pb-6 bg-white dark:bg-gray-700"
/>
</div>
<div className="flex flex-col md:flex-row items-center justify-between mt-"> {/* Adjusted layout and added top margin */}
<Link href={`/dash`} className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800 mt-4 md:mt-0">
Отказ
</Link>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
Запази
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,180 @@
import axiosInstance from '../../src/axiosSecure';
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
import { useSession } from "next-auth/react"
const common = require('src/helpers/common');
// ------------------ ------------------
// This component is used to create and edit
/* location model:
model Report {
id Int @id @default(autoincrement())
date DateTime
publisherId String
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
locationId Int?
location Location? @relation(fields: [locationId], references: [id])
shift Shift?
placementCount Int?
videoCount Int?
returnVisitInfoCount Int?
conversationCount Int?
experienceInfo String? @db.LongText
}
*/
export default function ReportForm({ shiftId, existingItem, onDone }) {
const router = useRouter();
const getFormattedDate = (date) => {
let year = date.getFullYear();
let month = (1 + date.getMonth()).toString().padStart(2, '0');
let day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
};
const initialDate = getFormattedDate(new Date());
const { data: session, status } = useSession()
const [publisherId, setPublisher] = useState(null);
useEffect(() => {
if (session) {
setPublisher(session.user.id);
}
}, [session]);
const [item, setItem] = useState(existingItem || {
experienceInfo: "",
date: existingItem?.date || initialDate,
shiftId: shiftId,
publisherId: publisherId,
placementCount: 0,
videoCount: 0,
returnVisitInfoCount: 0,
conversationCount: 0
});
const [shifts, setShifts] = useState([]);
useEffect(() => {
const fetchData = async () => {
try {
const dateStr = common.getISODateOnly(new Date(item.date));
const { data: shiftsForDate } = await axiosInstance.get(`/api/?action=getShiftsForDay&date=${dateStr}`);
setShifts(shiftsForDate);
if (!existingItem && shiftsForDate.length > 0) {
setItem((prevItem) => ({ ...prevItem, shiftId: shiftsForDate[0].id }));
}
} catch (error) {
console.error(error);
}
};
fetchData();
}, [item.date, existingItem]);
const handleChange = ({ target }) => {
setItem({ ...item, [target.name]: target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
item.publisher = { connect: { id: publisherId } };
item.shift = { connect: { id: parseInt(item.shiftId) } };
item.date = new Date(item.date);
delete item.publisherId;
delete item.shiftId;
item.placementCount = parseInt(item.placementCount);
item.videoCount = parseInt(item.videoCount);
item.returnVisitInfoCount = parseInt(item.returnVisitInfoCount);
item.conversationCount = parseInt(item.conversationCount);
// item.location = { connect: { id: parseInt(item.locationId) } };s
console.log("handleSubmit");
console.log(item);
try {
const response = await axiosInstance.post('/api/data/reports', item);
console.log(response);
toast.success("Гоово. Благодарим Ви за отчета!");
setTimeout(() => {
if (onDone) {
onDone();
} else {
router.push(`/dash`);
}
}, 300); // Delay for 3 seconds
} catch (error) {
console.log(error);
toast.error("За съжаление възникна грешка!");
}
}
return (
<div className="w-full max-w-md mx-auto">
{/* <iframe src="https://docs.google.com/forms/d/e/1FAIpQLSdjbqgQEGY5-fA4A0B4cXjKRQVRWk5_-uoHVIAwdMcZ5bB7Zg/viewform?embedded=true" width="640" height="717" frameborder="0" marginheight="0" marginwidth="0">Loading…</iframe> */}
<form className="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
<h1 className="text-2xl font-bold mb-8">Отчет от смяна</h1>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="date">
Дата
</label>
<input className="textbox form-input px-4 py-2 rounded" id="date" name="date" type="date" onChange={handleChange} value={item.date} autoComplete="off" />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="shiftId">
Смяна
</label>
<select className="textbox form-select px-4 py-2 rounded"
id="shiftId" name="shiftId" onChange={handleChange} value={item.shiftId} autoComplete="off" >
{shifts.map((shift) => (
<option key={shift.id} value={shift.id}>
{shift.name}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="placementCount">
Издания
</label>
<input className="textbox form-input px-4 py-2 rounded" id="placementCount" name="placementCount" type="number" onChange={handleChange} value={item.placementCount} autoComplete="off" />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="conversationCount">
Разговори
</label>
<input className="textbox form-input px-4 py-2 rounded" id="conversationCount" name="conversationCount" type="number" onChange={handleChange} value={item.conversationCount} autoComplete="off" />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="videoCount">
Клипове
</label>
<input className="textbox form-input px-4 py-2 rounded" id="videoCount" name="videoCount" type="number" onChange={handleChange} value={item.videoCount} autoComplete="off" />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="returnVisitInfoCount">
Адреси / Телефони
</label>
<input className="textbox form-input px-4 py-2 rounded" id="returnVisitInfoCount" name="returnVisitInfoCount" type="number" onChange={handleChange} value={item.returnVisitInfoCount} autoComplete="off" />
</div>
<div className="flex items-center justify-between">
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" type="submit">
Запази
</button>
<Link href={`/dash`}>
Отказ
</Link>
</div>
</form>
</div>
);
}

209
components/sidebar.tsx Normal file
View File

@ -0,0 +1,209 @@
import { signIn, signOut, useSession } from "next-auth/react";
import styles from "../styles/header.module.css";
import React, { useState, useEffect, useRef } from "react";
import { useRouter } from 'next/router';
import sidemenu, { footerMenu } from './sidemenuData.js'; // Move sidemenu data to a separate file
import axiosInstance from "src/axiosSecure";
import common from "src/helpers/common";
//get package version from package.json
const packageVersion = require('../package.json').version;
function SidebarMenuItem({ item, session, isSubmenu }) {
const router = useRouter();
const isActive = router.pathname.includes(item.url);
const collapsable = item.collapsable === undefined ? true : item.collapsable;
// is open is always true if not collapsable; isOpen is true if not collapsable
//const [isOpen, setIsOpen] = useState(false && collapsable);
// Initialize isOpen to true for non-collapsible items, ensuring they are always "open" // xOR
const baseClass = `sidemenu-item flex items-center ${isSubmenu ? "px-4 py-1" : ""} mt-1 transition-colors duration-3000 transform rounded-md`;
const activeClass = isActive ? "sidemenu-item-active text-blue-600 bg-gray-100 dark:text-blue-400 dark:bg-blue-900" : "text-gray-700 dark:text-gray-300";
const hoverClasses = "hover:bg-gray-100 dark:hover:bg-gray-800 dark:hover:text-gray-200 hover:text-gray-700";
const initialState = common.getLocalStorage(`sidebar-openState-${item.id}`, isActive);
const [isOpen, setIsOpen] = useState(() => common.getLocalStorage(`sidebar-openState-${item.id}`, isActive));
useEffect(() => {
// Only run this effect on the client-side and if it's a submenu item
if (typeof window !== 'undefined' && isSubmenu) {
common.setLocalStorage(`sidebar-openState-${item.id}`, isOpen);
}
}, [isOpen, item.id, isSubmenu]);
useEffect(() => {
// This effect should also check for window to ensure it's client-side
if (typeof window !== 'undefined' && isSubmenu) {
const isAnyChildActive = item.children?.some(child => router.pathname.includes(child.url));
if (isActive || isAnyChildActive) {
setIsOpen(true);
}
}
}, [router.pathname, isActive, item.children, isSubmenu]);
if (!session || (item.roles && !item.roles.includes(session?.user?.role))) {
return null;
}
const handleClick = () => {
//console.log("clicked", item);
if (item.children && collapsable) { // Toggle isOpen only if item is collapsable and has children
setIsOpen(!isOpen);
} else if (item.url) {
router.push(item.url);
}
};
const clickableClass = item.url || item.children ? "cursor-pointer" : "";
return (
<>
<div className={`${baseClass} ${activeClass} ${hoverClasses} ${clickableClass}`}
onClick={handleClick}>
{item.svgData && <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d={item.svgData} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>}
<span className="mx-4 font-medium">{item.text}</span>
{item.children && <DropDownIcon isOpen={isOpen} />}
</div>
{isOpen && item.children && (
// <ul className="relative accordion-collapse show">
<ul className="pl-2 mt-1">
{item.children.map((child, index) => (
<SidebarMenuItem key={index} item={child} session={session} isSubmenu={true} />
))}
</ul>
)}
</>
);
}
function DropDownIcon({ isOpen }) {
return (
<svg aria-hidden="false" focusable="false" className="w-3 h-3 ml-auto" viewBox="0 0 448 512">
{/* svg content */}
</svg>
);
}
export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
const { data: session, status } = useSession();
const sidebarWidth = 256; // Simplify by using a constant
const sidebarRef = useRef(null);
//const [locations, setLocations] = useState([]);
useEffect(() => {
const fetchLocations = async () => {
try {
const response = await axiosInstance.get('/api/data/locations'); // Adjust the API endpoint as needed
const locationsData = response.data
.filter(location => location.isactive === true)
.map(location => ({
text: location.name,
url: `/cart/locations/${location.id}`,
}));
// Find the "Locations" menu item and populate its children with locationsData
const menuIndex = sidemenu.findIndex(item => item.id === "locations");
if (menuIndex !== -1) {
sidemenu[menuIndex].children = locationsData;
}
//setLocations(locationsData); // Optional, if you need to use locations elsewhere
} catch (error) {
console.error("Error fetching locations:", error);
}
};
fetchLocations();
}, []);
if (status === "loading") {
return <div>Loading...</div>;
}
return (
<>
<button onClick={toggleSidebar}
className="fixed top-0 left-0 z-40 m-4 text-xl bg-white border border-gray-200 p-2 rounded-full shadow-lg focus:outline-none"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}></button>
<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 w-64"
style={{ width: `${sidebarWidth}px`, transform: isSidebarOpen ? 'translateX(0)' : `translateX(-${sidebarWidth - 16}px)` }}>
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white pt-2 pl-4 pb-4"
title={`v.${packageVersion} ${process.env.GIT_COMMIT_ID}`} >Специално Свидетелстване София</h2>
<div className="flex flex-col justify-between pb-4">
<nav>
{sidemenu.map((item, index) => (
<SidebarMenuItem key={index} item={item} session={session} />
))}
<hr className="my-6 border-gray-200 dark:border-gray-600" />
{/* User section */}
<UserSection session={session} />
{/* Footer section: smaller lighter text */}
<div className="mt-auto">
<hr className="border-gray-200 dark:border-gray-600 text-align-bottom" />
<FooterSection />
</div>
</nav>
</div>
</aside>
</>
);
}
function UserSection({ session }) {
return (
<div className="sidemenu-item flex items-center">
{!session ? <SignInButton /> : <UserDetails session={session} />}
</div>
);
}
function SignInButton() {
return (
<div className="items-center py-6" onClick={() => signIn()}>
<a href="/api/auth/signin">Впишете се</a>
</div>
);
}
function UserDetails({ session }) {
return (
<>
<hr className="m-0" />
<div className="flex items-center py-4 -mx-2">
{session.user.image && (
<img className="object-cover mx-2 rounded-full h-9 w-9" src={session.user.image} alt="avatar" />
)}
<div className="ml-3 overflow-hidden">
<p className="mx-2 mt-2 text-sm font-medium text-gray-800 dark:text-gray-200">{session.user.name}</p>
<p className="mx-2 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>
</div>
</div>
</>
);
}
function FooterSection() {
const router = useRouter();
const navigateTo = (url) => {
router.push(url);
};
return (
footerMenu.map((item, index) => (
<div
key={index}
className="px-4 py-2 mt-2 cursor-pointer hover:underline hover:text-blue-600 dark:hover:text-blue-400 "
onClick={() => navigateTo(item.url)}
>
<span className="text-gray-700 dark:text-gray-300 font-medium">{item.text}</span>
</div>
))
);
}

118
components/sidemenuData.js Normal file
View File

@ -0,0 +1,118 @@
import { UserRole } from "@prisma/client";
const sidemenu = [
{
id: "dashboard",
text: "Предпочитания",
url: "/dash",
roles: [UserRole.ADMIN, UserRole.USER, UserRole.POWERUSER],
svgData:
"M19 11H5M19 11C20.1046 11 21 11.8954 21 13V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13C3 11.8954 3.89543 11 5 11M19 11V9C19 7.89543 18.1046 7 17 7M5 11V9C5 7.89543 5.89543 7 7 7M7 7V5C7 3.89543 7.89543 3 9 3H15C16.1046 3 17 3.89543 17 5V7M7 7H17",
},
{
id: "shedule",
text: "График",
url: "/cart/calendar/schedule",
},
{
id: "locations",
text: "Местоположения",
svgData: "M12 2C8.13401 2 5 5.13401 5 9C5 14.25 12 22 12 22C12 22 19 14.25 19 9C19 5.13401 15.866 2 12 2ZM12 11.5C10.6193 11.5 9.5 10.3807 9.5 9C9.5 7.61929 10.6193 6.5 12 6.5C13.3807 6.5 14.5 7.61929 14.5 9C14.5 10.3807 13.3807 11.5 12 11.5Z", // Example SVG path for location icon
url: "#",
children: [], // Placeholder to be dynamically populated
collapsable: true,
url: "/cart/locations",
},
{
id: "cart-report",
text: "Отчет",
url: "/cart/reports/report",
},
{
id: "cart-experience",
text: "Случка",
url: "/cart/reports/experience",
},
{
id: "guidelines",
text: "Напътствия",
url: "/guidelines",
},
{
id: "contactAll",
text: "Контакти",
url: "/cart/publishers/contacts",
},
{
id: "contactUs",
text: "За връзка",
url: "/contactUs",
},
{
id: "admin",
text: "Админ",
url: "/admin",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
svgData:
"M19 11H5M19 11C20.1046 11 21 11.8954 21 13V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13C3 11.8954 3.89543 11 5 11M19 11V9C19 7.89543 18.1046 7 17 7M5 11V9C5 7.89543 5.89543 7 7 7M7 7V5C7 3.89543 7.89543 3 9 3H15C16.1046 3 17 3.89543 17 5V7M7 7H17",
children: [
{
id: "cart-places",
text: "Места",
url: "/cart/locations",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
},
{
id: "cart-publishers",
text: "Вестители",
url: "/cart/publishers",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
},
// {
// id: "cart-availability",
// text: "Достъпности",
// url: "/cart/availabilities",
// roles: [UserRole.ADMIN, UserRole.POWERUSER],
// },
{
id: "cart-events",
text: "План",
url: "/cart/cartevents",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
},
{
id: "cart-calendar",
text: "Календар",
url: "/cart/calendar",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
}, {
id: "cart-reports",
text: "Отчети",
url: "/cart/reports/list",
roles: [UserRole.ADMIN, UserRole.POWERUSER],
},
]
},
];
const footerMenu = [
{
id: "profile",
text: "Настройки",
url: `/cart/publishers/edit/me`,
roles: [UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER],
svgData:
"M16 7C16 9.20914 14.2091 11 12 11C9.79086 11 8 9.20914 8 7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7Z M12 14C8.13401 14 5 17.134 5 21H19C19 17.134 15.866 14 12 14Z"
},
{
id: "privacy-policy",
text: "Поверителност",
url: "/privacy",
},
]
export { footerMenu };
export default sidemenu;

View File

@ -0,0 +1,81 @@
import { PickersLocaleText } from './utils/pickersLocaleTextApi';
import { getPickersLocalization } from './utils/getPickersLocalization';
// Този обект не е Partial<PickersLocaleText>, защото това са стойностите по подразбиране
const bgBGPickers: PickersLocaleText<any> = {
// Навигация в календара
previousMonth: 'Предишен месец',
nextMonth: 'Следващ месец',
// Навигация на изгледа
openPreviousView: 'отвори предишен изглед',
openNextView: 'отвори следващ изглед',
calendarViewSwitchingButtonAriaLabel: (view) =>
view === 'year'
? 'отворен е годишен изглед, превключи към календарен изглед'
: 'отворен е календарен изглед, превключи към годишен изглед',
// Мяста за дата
start: 'Начало',
end: 'Край',
// Акционен бар
cancelButtonLabel: 'Отказ',
clearButtonLabel: 'Изчисти',
okButtonLabel: 'ОК',
todayButtonLabel: 'Днес',
// Заглавия на инструментална лента
datePickerToolbarTitle: 'Изберете дата',
dateTimePickerToolbarTitle: 'Изберете дата и час',
timePickerToolbarTitle: 'Изберете час',
dateRangePickerToolbarTitle: 'Изберете период на дата',
// Етикети на часовника
clockLabelText: (view, time, adapter) =>
`Изберете ${view}. ${time === null ? 'Не е избрано време' : `Избраният час е ${adapter.format(time, 'fullTime')}`
}`,
hoursClockNumberText: (hours) => `${hours} часа`,
minutesClockNumberText: (minutes) => `${minutes} минути`,
secondsClockNumberText: (seconds) => `${seconds} секунди`,
// Етикети на цифров часовник
selectViewText: (view) => `Изберете ${view}`,
// Етикети на календара
calendarWeekNumberHeaderLabel: 'Номер на седмица',
calendarWeekNumberHeaderText: '#',
calendarWeekNumberAriaLabelText: (weekNumber) => `Седмица ${weekNumber}`,
calendarWeekNumberText: (weekNumber) => `${weekNumber}`,
// Етикети за отваряне на избора
openDatePickerDialogue: (value, utils) =>
value !== null && utils.isValid(value)
? `Изберете дата, избраната дата е ${utils.format(value, 'fullDate')}`
: 'Изберете дата',
openTimePickerDialogue: (value, utils) =>
value !== null && utils.isValid(value)
? `Изберете час, избраният час е ${utils.format(value, 'fullTime')}`
: 'Изберете час',
fieldClearLabel: 'Изчисти стойност',
// Етикети на таблицата
timeTableLabel: 'изберете време',
dateTableLabel: 'изберете дата',
// Заместващи текстове в секции на полета
fieldYearPlaceholder: (params) => 'Y'.repeat(params.digitAmount),
fieldMonthPlaceholder: (params) => (params.contentType === 'letter' ? 'MMMM' : 'MM'),
fieldDayPlaceholder: () => 'DD',
fieldWeekDayPlaceholder: (params) => (params.contentType === 'letter' ? 'EEEE' : 'EE'),
fieldHoursPlaceholder: () => 'hh',
fieldMinutesPlaceholder: () => 'mm',
fieldSecondsPlaceholder: () => 'ss',
fieldMeridiemPlaceholder: () => 'aa',
};
export const DEFAULT_LOCALE = bgBGPickers;
export const bgBG = getPickersLocalization(bgBGPickers);

View File

@ -0,0 +1,13 @@
import { PickersLocaleText } from './pickersLocaleTextApi';
export const getPickersLocalization = (pickersTranslations: Partial<PickersLocaleText<any>>) => {
return {
components: {
MuiLocalizationProvider: {
defaultProps: {
localeText: { ...pickersTranslations },
},
},
},
};
};

View File

@ -0,0 +1,115 @@
import { TimeViewWithMeridiem } from '../../internals/models';
import { DateView, TimeView, MuiPickersAdapter, FieldSectionContentType } from '../../models';
export interface PickersComponentSpecificLocaleText {
/**
* Title displayed in the toolbar of the `DatePicker` and its variants.
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
*/
datePickerToolbarTitle: string;
/**
* Title displayed in the toolbar of the `TimePicker` and its variants.
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
*/
timePickerToolbarTitle: string;
/**
* Title displayed in the toolbar of the `DateTimePicker` and its variants.
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
*/
dateTimePickerToolbarTitle: string;
/**
* Title displayed in the toolbar of the `DateRangePicker` and its variants.
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
*/
dateRangePickerToolbarTitle: string;
}
export interface PickersComponentAgnosticLocaleText<TDate> {
// Calendar navigation
previousMonth: string;
nextMonth: string;
// Calendar week number
calendarWeekNumberHeaderLabel: string;
calendarWeekNumberHeaderText: string;
calendarWeekNumberAriaLabelText: (weekNumber: number) => string;
calendarWeekNumberText: (weekNumber: number) => string;
// View navigation
openPreviousView: string;
openNextView: string;
calendarViewSwitchingButtonAriaLabel: (currentView: DateView) => string;
// DateRange placeholders
start: string;
end: string;
// Action bar
cancelButtonLabel: string;
clearButtonLabel: string;
okButtonLabel: string;
todayButtonLabel: string;
// Clock labels
clockLabelText: (view: TimeView, time: TDate | null, adapter: MuiPickersAdapter<TDate>) => string;
hoursClockNumberText: (hours: string) => string;
minutesClockNumberText: (minutes: string) => string;
secondsClockNumberText: (seconds: string) => string;
// Digital clock labels
selectViewText: (view: TimeViewWithMeridiem) => string;
// Open picker labels
openDatePickerDialogue: (date: TDate | null, utils: MuiPickersAdapter<TDate>) => string;
openTimePickerDialogue: (date: TDate | null, utils: MuiPickersAdapter<TDate>) => string;
// Clear button label
fieldClearLabel: string;
// Table labels
timeTableLabel: string;
dateTableLabel: string;
// Field section placeholders
fieldYearPlaceholder: (params: { digitAmount: number; format: string }) => string;
fieldMonthPlaceholder: (params: {
contentType: FieldSectionContentType;
format: string;
}) => string;
fieldDayPlaceholder: (params: { format: string }) => string;
fieldWeekDayPlaceholder: (params: {
contentType: FieldSectionContentType;
format: string;
}) => string;
fieldHoursPlaceholder: (params: { format: string }) => string;
fieldMinutesPlaceholder: (params: { format: string }) => string;
fieldSecondsPlaceholder: (params: { format: string }) => string;
fieldMeridiemPlaceholder: (params: { format: string }) => string;
}
export interface PickersLocaleText<TDate>
extends PickersComponentAgnosticLocaleText<TDate>,
PickersComponentSpecificLocaleText { }
export type PickersInputLocaleText<TDate> = Partial<PickersLocaleText<TDate>>;
/**
* Translations that can be provided directly to the picker components.
* It contains some generic translations like `toolbarTitle`
* which will be dispatched to various translations keys in `PickersLocaleText`, depending on the pickers received them.
*/
export interface PickersInputComponentLocaleText<TDate>
extends Partial<PickersComponentAgnosticLocaleText<TDate>> {
/**
* Title displayed in the toolbar of this picker.
* Will override the global translation keys like `datePickerToolbarTitle` passed to the `LocalizationProvider`.
*/
toolbarTitle?: string;
}
export type PickersTranslationKeys = keyof PickersLocaleText<any>;
export type LocalizedComponent<
TDate,
Props extends { localeText?: PickersInputComponentLocaleText<TDate> },
> = Omit<Props, 'localeText'> & { localeText?: PickersInputLocaleText<TDate> };

11
config.json Normal file
View File

@ -0,0 +1,11 @@
{
"checkboxUI": {
"enabled": true,
"timeIncrements": 1.5,
"options": {
"allDay": true,
"morningTransport": true,
"eveningTransport": true
}
}
}

1416
content/April.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
-----END CERTIFICATE-----

View File

@ -0,0 +1 @@
[]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
fuzzySearchResult.txt Normal file

File diff suppressed because one or more lines are too long

12
me.tsx Normal file
View File

@ -0,0 +1,12 @@
import { useSession } from "next-auth/react"
import Layout from "./components/layout"
export default function MePage() {
const { data } = useSession()
return (
<Layout>
<pre>{JSON.stringify(data, null, 2)}</pre>
</Layout>
)
}

41
middleware.ts Normal file
View File

@ -0,0 +1,41 @@
import { withAuth } from "next-auth/middleware";
import { UserRole } from "@prisma/client";
// More on how NextAuth.js middleware works: https://next-auth.js.org/configuration/nextjs#middleware
export default withAuth({
callbacks: {
authorized({ req, token }) {
console.log("req", req);
// `/admin` requires admin role
if (req.nextUrl.pathname === "/examples/admin") {
return token?.userRole === "adminer"
}
if (req.nextUrl.pathname === "/cart" && token?.role === UserRole.ADMIN) {
// return NextResponse.redirect(new URL("/", req.url));
return true;
}
// if(req.nextUrl.pathname === "/cart"){
// return token?.role !== Role.ADMIN;
// }
// `/me` only requires the user to be logged in
return !!token
},
},
})
export const config = {
// matcher: ["/admin", "/me"]
matcher: ["/admin/:path*", "/me", "/cart, /"],
// callbackUrl: {
// name: `__Secure-next-auth.callback-url`,
// options: {
// sameSite: 'lax',
// path: '/',
// secure: true
// }
// },
}

31
next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
// import { Role } from "@prisma/client";
// import { DefaultSession } from "next-auth";
// import { JWT } from "next-auth/jwt";
// Read more at: https://next-auth.js.org/getting-started/typescript#module-augmentation
declare module "next-auth/jwt" {
interface JWT {
/** The user's role. */
role?: Role;
userRole?: "admin"
}
}
// declare module "next-auth" {
// /**
// * Returned by `useSession`, `getSession` and received as a prop on the
// * `SessionProvider` React Context and trpc context
// */
// interface Session {
// user?: {
// role?: Role;
// } & DefaultSession["user"];
// }
// /** Passed as a parameter to the `jwt` callback */
// interface User {
// role?: Role;
// }
// }

45
next.config.js Normal file
View File

@ -0,0 +1,45 @@
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
// !! WARN !!
ignoreBuildErrors: true,
},
compress: false,
pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?`
env: {
env: process.env.NODE_ENV,
server: 'http://' + process.env.NEXT_PUBLIC_HOST + ':' + process.env.NEXT_PUBLIC_PORT + '',
},
webpack(config, { isServer }) {
config.optimization.minimize = false;
productionBrowserSourceMaps: true,
config.resolve.fallback = {
// if you miss it, all the other options in fallback, specified
// 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) {
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: true,
generateStatsFile: true,
})
);
}
return config;
},
}

7
nodemon.json Normal file
View File

@ -0,0 +1,7 @@
{
"verbose": true,
"ignore": ["node_modules", ".next"],
"watch": ["server/**/*", "server.js","src/helpers.js","src/**/*", "next.config.js"],
"ext": "js json"
}

BIN
output.docx Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More