kafka add docker and codestyle and smtp many comp

This commit is contained in:
romantarkin 2025-07-23 14:33:44 +05:00
parent f3aeaad948
commit 98e5091742
16 changed files with 481 additions and 57 deletions

View File

@ -31,6 +31,9 @@ services:
DB_USER: authuser DB_USER: authuser
DB_PASSWORD: authpassword DB_PASSWORD: authpassword
JWT_SECRET: supersecretkey JWT_SECRET: supersecretkey
KAFKA_BROKER: kafka:9092
KAFKA_CLIENT_ID: auth-service
KAFKA_GROUP_ID: auth-service-group
volumes: volumes:
- ./auth-service:/usr/src/app - ./auth-service:/usr/src/app
working_dir: /usr/src/app working_dir: /usr/src/app
@ -53,6 +56,9 @@ services:
DB_NAME: authdb DB_NAME: authdb
DB_USER: authuser DB_USER: authuser
DB_PASSWORD: authpassword DB_PASSWORD: authpassword
KAFKA_BROKER: kafka:9092
KAFKA_CLIENT_ID: mail-service
KAFKA_GROUP_ID: mail-service-group
nginx-proxy-manager: nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:latest' image: 'jc21/nginx-proxy-manager:latest'
@ -76,5 +82,39 @@ services:
auth-service: auth-service:
condition: service_started condition: service_started
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- '2181:2181'
volumes:
- zookeeper_data:/var/lib/zookeeper/data
- zookeeper_log:/var/lib/zookeeper/log
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: kafka
depends_on:
- zookeeper
ports:
- '9092:9092'
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
volumes:
- kafka_data:/var/lib/kafka/data
volumes: volumes:
mysql_data: mysql_data:
kafka_data:
zookeeper_data:
zookeeper_log:

View File

@ -12,7 +12,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"react-scripts": "5.0.1" "react-scripts": "5.0.1",
"react-select": "^5.10.2"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -2218,6 +2219,122 @@
"postcss-selector-parser": "^6.0.10" "postcss-selector-parser": "^6.0.10"
} }
}, },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/serialize": "^1.3.3",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
"find-root": "^1.1.0",
"source-map": "^0.5.7",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"hoist-non-react-statics": "^3.3.1"
},
"peerDependencies": {
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@emotion/serialize": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/unitless": "^0.10.0",
"@emotion/utils": "^1.4.2",
"csstype": "^3.0.2"
}
},
"node_modules/@emotion/sheet": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -2289,6 +2406,28 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
"integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
"integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
"dependencies": {
"@floating-ui/core": "^1.7.2",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -3443,6 +3582,23 @@
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
}, },
"node_modules/@types/react": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@ -5795,6 +5951,11 @@
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="
}, },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -6077,6 +6238,15 @@
"utila": "~0.4" "utila": "~0.4"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@ -7417,6 +7587,11 @@
"url": "https://github.com/avajs/find-cache-dir?sponsor=1" "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
} }
}, },
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -8055,6 +8230,19 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/hoopy": { "node_modules/hoopy": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@ -10326,6 +10514,11 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@ -12835,6 +13028,41 @@
} }
} }
}, },
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -14266,6 +14494,11 @@
"postcss": "^8.2.15" "postcss": "^8.2.15"
} }
}, },
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -15136,6 +15369,19 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -7,7 +7,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"react-scripts": "5.0.1" "react-scripts": "5.0.1",
"react-select": "^5.10.2"
}, },
"proxy": "http://project.ru", "proxy": "http://project.ru",
"scripts": { "scripts": {

View File

@ -2,7 +2,6 @@ import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import logo from './logo.svg';
import './App.css'; import './App.css';
function App() { function App() {

View File

@ -0,0 +1,46 @@
import React, { useRef, useState } from 'react';
import styles from '../styles/MultiSelect.module.css';
export default function MultiSelect({ options, value, onChange, placeholder = 'Выберите...', disabled }) {
const [open, setOpen] = useState(false);
const ref = useRef();
const handleToggle = () => {
if (!disabled) setOpen((o) => !o);
};
const handleSelect = (id) => {
if (value.includes(id)) {
onChange(value.filter((v) => v !== id));
} else {
onChange([...value, id]);
}
};
const handleBlur = (e) => {
if (!ref.current.contains(e.relatedTarget)) setOpen(false);
};
return (
<div className={styles.wrapper} tabIndex={0} ref={ref} onBlur={handleBlur}>
<div className={styles.control + (disabled ? ' ' + styles.disabled : '')} onClick={handleToggle}>
<span className={styles.value}>
{value.length === 0 ? <span className={styles.placeholder}>{placeholder}</span> :
options.filter(o => value.includes(o.value)).map(o => o.label).join(', ')}
</span>
<span className={styles.arrow}>{open ? '▲' : '▼'}</span>
</div>
{open && !disabled && (
<div className={styles.dropdown}>
{options.map(opt => (
<div
key={opt.value}
className={styles.option + (value.includes(opt.value) ? ' ' + styles.selected : '')}
onClick={() => handleSelect(opt.value)}
tabIndex={-1}
>
<input type="checkbox" checked={value.includes(opt.value)} readOnly /> {opt.label}
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import Modal from './Modal'; import Modal from './Modal';
import styles from '../styles/CampaignModal.module.css'; import styles from '../styles/CampaignModal.module.css';
import MultiSelect from '../components/MultiSelect';
export default function CreateCampaignModal({ isOpen, onClose, campaign, groups, versions, loading, onChange, onSave, getVersionName }) { export default function CreateCampaignModal({ isOpen, onClose, campaign, groups, versions, smtpServers, loading, onChange, onSave, getVersionName }) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}> <Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
<h3 className={styles.title}>Добавить кампанию</h3> <h3 className={styles.title}>Добавить кампанию</h3>
@ -24,6 +25,15 @@ export default function CreateCampaignModal({ isOpen, onClose, campaign, groups,
<label className={styles.label}>Тема <label className={styles.label}>Тема
<input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} /> <input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} />
</label> </label>
<label className={styles.label}>SMTP-серверы
<MultiSelect
options={smtpServers.map(s => ({ value: s.id, label: `${s.name} (${s.host})` }))}
value={campaign.smtp_server_ids || []}
onChange={ids => onChange({ ...campaign, smtp_server_ids: ids })}
placeholder="Выберите SMTP-серверы"
disabled={loading}
/>
</label>
<label className={styles.label}>Статус <label className={styles.label}>Статус
<select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}> <select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}>
<option value="draft">Черновик</option> <option value="draft">Черновик</option>

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import Modal from './Modal'; import Modal from './Modal';
import styles from '../styles/CampaignModal.module.css'; import styles from '../styles/CampaignModal.module.css';
import MultiSelect from '../components/MultiSelect';
export default function EditCampaignModal({ isOpen, onClose, campaign, groups, versions, loading, onChange, onSave, getVersionName }) { export default function EditCampaignModal({ isOpen, onClose, campaign, groups, versions, smtpServers, loading, onChange, onSave, getVersionName }) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}> <Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
<h3 className={styles.title}>Редактировать кампанию</h3> <h3 className={styles.title}>Редактировать кампанию</h3>
@ -24,6 +25,15 @@ export default function EditCampaignModal({ isOpen, onClose, campaign, groups, v
<label className={styles.label}>Тема <label className={styles.label}>Тема
<input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} /> <input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} />
</label> </label>
<label className={styles.label}>SMTP-серверы
<MultiSelect
options={smtpServers.map(s => ({ value: s.id, label: `${s.name} (${s.host})` }))}
value={campaign.smtp_server_ids || []}
onChange={ids => onChange({ ...campaign, smtp_server_ids: ids })}
placeholder="Выберите SMTP-серверы"
disabled={loading}
/>
</label>
<label className={styles.label}>Статус <label className={styles.label}>Статус
<select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}> <select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}>
<option value="draft">Черновик</option> <option value="draft">Черновик</option>

View File

@ -20,6 +20,7 @@ function CampaignPage() {
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [versions, setVersions] = useState([]); const [versions, setVersions] = useState([]);
const [smtpServers, setSmtpServers] = useState([]);
useEffect(() => { useEffect(() => {
fetchCampaigns(page); fetchCampaigns(page);
@ -29,6 +30,7 @@ function CampaignPage() {
useEffect(() => { useEffect(() => {
fetchGroups(); fetchGroups();
fetchVersions(); fetchVersions();
fetchSmtpServers();
// eslint-disable-next-line // eslint-disable-next-line
}, []); }, []);
@ -94,6 +96,24 @@ function CampaignPage() {
} }
}; };
const fetchSmtpServers = async () => {
try {
const res = await fetch('/api/mail/smtp-servers?limit=1000', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (res.ok && Array.isArray(data)) {
setSmtpServers(data);
} else if (res.ok && Array.isArray(data.rows)) {
setSmtpServers(data.rows);
} else {
setSmtpServers([]);
}
} catch {
setSmtpServers([]);
}
};
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!window.confirm('Удалить кампанию?')) return; if (!window.confirm('Удалить кампанию?')) return;
setDeleteLoading(id); setDeleteLoading(id);
@ -116,7 +136,10 @@ function CampaignPage() {
}; };
const handleEdit = (c) => { const handleEdit = (c) => {
setEditCampaign(c); setEditCampaign({
...c,
smtp_server_ids: c.SmtpServers ? c.SmtpServers.map(s => s.id) : [],
});
}; };
const handleEditSave = async (e) => { const handleEditSave = async (e) => {
@ -129,7 +152,7 @@ function CampaignPage() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}) ...(token ? { Authorization: `Bearer ${token}` } : {})
}, },
body: JSON.stringify({ ...editCampaign, user_id: user?.id }) body: JSON.stringify({ ...editCampaign, user_id: user?.id, smtp_server_ids: editCampaign.smtp_server_ids })
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
@ -152,6 +175,7 @@ function CampaignPage() {
subject_override: '', subject_override: '',
scheduled_at: '', scheduled_at: '',
status: 'draft', status: 'draft',
smtp_server_ids: [],
}); });
}; };
@ -165,7 +189,7 @@ function CampaignPage() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}) ...(token ? { Authorization: `Bearer ${token}` } : {})
}, },
body: JSON.stringify({ ...createCampaign, user_id: user?.id }) body: JSON.stringify({ ...createCampaign, user_id: user?.id, smtp_server_ids: createCampaign.smtp_server_ids })
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
@ -246,6 +270,7 @@ function CampaignPage() {
campaign={editCampaign} campaign={editCampaign}
groups={groups} groups={groups}
versions={versions} versions={versions}
smtpServers={smtpServers}
loading={editLoading} loading={editLoading}
onChange={setEditCampaign} onChange={setEditCampaign}
onSave={handleEditSave} onSave={handleEditSave}
@ -259,6 +284,7 @@ function CampaignPage() {
campaign={createCampaign} campaign={createCampaign}
groups={groups} groups={groups}
versions={versions} versions={versions}
smtpServers={smtpServers}
loading={createLoading} loading={createLoading}
onChange={setCreateCampaign} onChange={setCreateCampaign}
onSave={handleCreateSave} onSave={handleCreateSave}
@ -272,13 +298,6 @@ function CampaignPage() {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
export default CampaignPage; export default CampaignPage;

View File

@ -202,13 +202,6 @@ function EmailTemplatesPage() {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
export default EmailTemplatesPage; export default EmailTemplatesPage;

View File

@ -424,13 +424,6 @@ function SubscribersInGroupPage({ group, onBack, token }) {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
export default GroupsPage; export default GroupsPage;

View File

@ -216,13 +216,6 @@ function SmtpServersPage() {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
export default SmtpServersPage; export default SmtpServersPage;

View File

@ -206,13 +206,6 @@ function UnsubscribedPage() {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
export default UnsubscribedPage; export default UnsubscribedPage;

View File

@ -239,13 +239,6 @@ function UsersPage() {
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' }; const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
const tdStyle = { padding: '10px 16px', background: '#fff' }; const tdStyle = { padding: '10px 16px', background: '#fff' };
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 }; const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
const modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' }; const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };

View File

@ -0,0 +1,70 @@
.wrapper {
position: relative;
min-width: 180px;
}
.control {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
border: 1.5px solid #c7d2fe;
border-radius: 8px;
padding: 10px 12px;
font-size: 16px;
cursor: pointer;
transition: border 0.2s;
min-height: 40px;
}
.control:focus, .control:active {
border-color: #6366f1;
}
.disabled {
background: #f3f4f6;
color: #b3b3b3;
cursor: not-allowed;
}
.value {
flex: 1;
white-space: pre-wrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder {
color: #9ca3af;
}
.arrow {
margin-left: 8px;
font-size: 14px;
color: #6366f1;
}
.dropdown {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
background: #fff;
border: 1.5px solid #c7d2fe;
border-radius: 8px;
box-shadow: 0 4px 24px 0 rgba(99,102,241,0.10);
z-index: 10;
max-height: 220px;
overflow-y: auto;
padding: 6px 0;
}
.option {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
color: #374151;
transition: background 0.15s;
}
.option:hover {
background: #f3f4f6;
}
.selected {
background: #e0e7ff;
color: #3730a3;
}

View File

@ -1,10 +1,15 @@
import { Campaign, EmailTemplateVersion, MailingGroup } from '../models/index.js'; import { Campaign, EmailTemplateVersion, MailingGroup, SmtpServer } from '../models/index.js';
export default { export default {
async create(req, res) { async create(req, res) {
try { try {
const campaign = await Campaign.create(req.body); const { smtp_server_ids, ...campaignData } = req.body;
res.status(201).json(campaign); const campaign = await Campaign.create(campaignData);
if (Array.isArray(smtp_server_ids)) {
await campaign.setSmtpServers(smtp_server_ids);
}
const campaignWithSmtps = await Campaign.findByPk(campaign.id, { include: [SmtpServer] });
res.status(201).json(campaignWithSmtps);
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }); res.status(400).json({ error: err.message });
} }
@ -14,7 +19,7 @@ export default {
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
const offset = parseInt(req.query.offset) || 0; const offset = parseInt(req.query.offset) || 0;
const result = await Campaign.findAndCountAll({ const result = await Campaign.findAndCountAll({
include: [EmailTemplateVersion, MailingGroup], include: [EmailTemplateVersion, MailingGroup, SmtpServer],
limit, limit,
offset, offset,
order: [['id', 'ASC']] order: [['id', 'ASC']]
@ -26,7 +31,7 @@ export default {
}, },
async getById(req, res) { async getById(req, res) {
try { try {
const campaign = await Campaign.findByPk(req.params.id, { include: [EmailTemplateVersion, MailingGroup] }); const campaign = await Campaign.findByPk(req.params.id, { include: [EmailTemplateVersion, MailingGroup, SmtpServer] });
if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
res.json(campaign); res.json(campaign);
} catch (err) { } catch (err) {
@ -35,10 +40,15 @@ export default {
}, },
async update(req, res) { async update(req, res) {
try { try {
const { smtp_server_ids, ...campaignData } = req.body;
const campaign = await Campaign.findByPk(req.params.id); const campaign = await Campaign.findByPk(req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
await campaign.update(req.body); await campaign.update(campaignData);
res.json(campaign); if (Array.isArray(smtp_server_ids)) {
await campaign.setSmtpServers(smtp_server_ids);
}
const campaignWithSmtps = await Campaign.findByPk(campaign.id, { include: [SmtpServer] });
res.json(campaignWithSmtps);
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }); res.status(400).json({ error: err.message });
} }

View File

@ -7,6 +7,7 @@ import EmailTemplateVersionModel from './emailTemplateVersion.js';
import CampaignModel from './campaign.js'; import CampaignModel from './campaign.js';
import DeliveryLogModel from './deliveryLog.js'; import DeliveryLogModel from './deliveryLog.js';
import SmtpServerModel from './smtpServer.js'; import SmtpServerModel from './smtpServer.js';
import { DataTypes } from 'sequelize';
const sequelize = new Sequelize( const sequelize = new Sequelize(
process.env.DB_NAME, process.env.DB_NAME,
@ -29,6 +30,9 @@ const Campaign = CampaignModel(sequelize);
const DeliveryLog = DeliveryLogModel(sequelize); const DeliveryLog = DeliveryLogModel(sequelize);
const SmtpServer = SmtpServerModel(sequelize); const SmtpServer = SmtpServerModel(sequelize);
// Промежуточная таблица для связи many-to-many
const CampaignSmtpServer = sequelize.define('CampaignSmtpServer', {}, { tableName: 'campaign_smtp_servers', timestamps: false });
// Связи // Связи
MailingGroup.belongsToMany(Subscriber, { through: GroupSubscriber, foreignKey: 'group_id', otherKey: 'subscriber_id' }); MailingGroup.belongsToMany(Subscriber, { through: GroupSubscriber, foreignKey: 'group_id', otherKey: 'subscriber_id' });
Subscriber.belongsToMany(MailingGroup, { through: GroupSubscriber, foreignKey: 'subscriber_id', otherKey: 'group_id' }); Subscriber.belongsToMany(MailingGroup, { through: GroupSubscriber, foreignKey: 'subscriber_id', otherKey: 'group_id' });
@ -44,6 +48,9 @@ Campaign.belongsTo(MailingGroup, { foreignKey: 'group_id' });
DeliveryLog.belongsTo(Campaign, { foreignKey: 'campaign_id' }); DeliveryLog.belongsTo(Campaign, { foreignKey: 'campaign_id' });
DeliveryLog.belongsTo(Subscriber, { foreignKey: 'subscriber_id' }); DeliveryLog.belongsTo(Subscriber, { foreignKey: 'subscriber_id' });
Campaign.belongsToMany(SmtpServer, { through: CampaignSmtpServer, foreignKey: 'campaign_id', otherKey: 'smtp_server_id' });
SmtpServer.belongsToMany(Campaign, { through: CampaignSmtpServer, foreignKey: 'smtp_server_id', otherKey: 'campaign_id' });
// Удалены связи SmtpServer <-> MailingGroup по group_id // Удалены связи SmtpServer <-> MailingGroup по group_id
// (связи с user_id только по полю, без внешнего ключа на User) // (связи с user_id только по полю, без внешнего ключа на User)
@ -58,4 +65,5 @@ export {
Campaign, Campaign,
DeliveryLog, DeliveryLog,
SmtpServer, SmtpServer,
CampaignSmtpServer,
}; };