1 Commits

Author SHA1 Message Date
ba479a671c save work 2025-03-25 11:29:54 +01:00
35 changed files with 476 additions and 2691 deletions

2
.gitignore vendored
View File

@ -137,5 +137,3 @@ dist
.pnp.*
data
public/uploads

169
.vscode/launch.json vendored
View File

@ -2,166 +2,45 @@
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: Start Development Server",
"type": "node",
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"command": "npm run dev",
"skipFiles": [
"<node_internals>/**"
],
"env": {
"NODE_ENV": "development"
"console": "integratedTerminal"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"webpack://_N_E/*": "${webRoot}/*"
}
},
{
"name": "Debug Send Emails (No Previous Emails)",
"type": "node",
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--require",
"ts-node/register"
],
"args": [
"${workspaceFolder}/src/scripts/send-emails-to-customers.ts",
"--count=0"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": [
"<node_internals>/**"
],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.scripts.json",
"NODE_ENV": "development"
"command": "npm run dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
},
"outFiles": [
"${workspaceFolder}/**/*.js"
]
"console": "integratedTerminal"
},
{
"name": "Debug Send Emails (1 Previous Email)",
"name": "Next.js: attach to server",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--require",
"ts-node/register"
],
"args": [
"${workspaceFolder}/src/scripts/send-emails-to-customers.ts",
"--count=1"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"request": "attach",
"port": 9229,
"skipFiles": [
"<node_internals>/**"
],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.scripts.json",
"NODE_ENV": "development"
},
"outFiles": [
"${workspaceFolder}/**/*.js"
]
},
{
"name": "Debug Send Emails (With LMStudio Config)",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--require",
"ts-node/register"
],
"args": [
"${workspaceFolder}/src/scripts/send-emails-to-customers.ts",
"--count=0",
"--lmstudio-url=http://localhost:1234/v1/chat/completions",
"--model=local-model",
"--temperature=0.7"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": [
"<node_internals>/**"
],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.scripts.json",
"NODE_ENV": "development",
"GMAIL_USER": "your.email@gmail.com",
"GMAIL_APP_PASSWORD": "your-app-password"
},
"outFiles": [
"${workspaceFolder}/**/*.js"
]
},
{
"name": "Debug Send Emails (Dry Run)",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--require",
"ts-node/register"
],
"args": [
"${workspaceFolder}/src/scripts/send-emails-to-customers.ts",
"--count=0",
"--dry-run=true"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": [
"<node_internals>/**"
],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.scripts.json",
"NODE_ENV": "development"
},
"outFiles": [
"${workspaceFolder}/**/*.js"
]
},
{
"name": "Import Customers from CSV",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--require",
"ts-node/register"
],
"args": [
"${workspaceFolder}/src/scripts/import-customers.ts",
"${input:csvFilePath}"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": [
"<node_internals>/**"
],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.scripts.json",
"NODE_ENV": "development"
},
"outFiles": [
"${workspaceFolder}/**/*.js"
]
}
],
"inputs": [
{
"id": "csvFilePath",
"type": "promptString",
"description": "Path to the CSV file to import",
"default": "./data/customers.csv"
}
]
}

View File

@ -0,0 +1,7 @@
I want add EmailTemplate Model
Tablename: EmailTemplate
id, content, modifiedAt, createdAt
Create branch features/emailtemplate
Make CRUD operation UI in Admin console
Then commit changes

View File

@ -1,5 +0,0 @@
Please add model EmailTemplate and make CRUD UI in admin console.
ModelName: EmailTamplate
id, content, modifiedAt, createdAt
create branch features/emailtemplate first and commit changes in the end

View File

@ -1,11 +0,0 @@
Please make script to import following csv to Customer model
City,Name,Website URL,Email
"Tokyo","teamLab Planets TOKYO","http://www.teamlab.art/e/planets/","null"
"Tokyo","Tokyo National Museum","http://www.tnm.jp/","null"
"Tokyo","Nezu Museum","http://www.nezu-muse.or.jp/","null"
- Name should be unique.
- Also if there are same email address skip the row.
Create new branch features/csvimport and commit when you finished

View File

@ -1,2 +0,0 @@
Please implement pagination to all list pages in admin
create new branch and please work in the branch

View File

@ -1,6 +0,0 @@
Please do following
- checkout to new brach features/gmail_sender
- create email.ts
- create function to send email usimg gmail smtp
- creadentials are in .env
- the function receives CustomerId and save to the Record

142
package-lock.json generated
View File

@ -18,15 +18,10 @@
"@editorjs/marker": "^1.4.0",
"@editorjs/paragraph": "^2.11.7",
"@editorjs/quote": "^2.7.6",
"@types/axios": "^0.14.4",
"@types/nodemailer": "^6.4.17",
"axios": "^1.8.4",
"bcrypt": "^5.1.1",
"csv-parse": "^5.6.0",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.13.0",
"next": "15.2.2",
"nodemailer": "^6.10.0",
"pg": "^8.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -1501,15 +1496,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/axios": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz",
"integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==",
"deprecated": "This is a stub types definition. axios provides its own type definitions, so you do not need this installed.",
"dependencies": {
"axios": "*"
}
},
"node_modules/@types/bcrypt": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
@ -1563,19 +1549,12 @@
"version": "20.17.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.24.tgz",
"integrity": "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.17",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
@ -2346,11 +2325,6 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@ -2386,16 +2360,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -2596,6 +2560,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -2760,17 +2725,6 @@
"color-support": "bin.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2810,11 +2764,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q=="
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -2966,14 +2915,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@ -3036,6 +2977,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -3187,6 +3129,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -3196,6 +3139,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -3233,6 +3177,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -3245,6 +3190,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -3875,25 +3821,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -3938,20 +3865,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -3980,6 +3893,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -4059,6 +3973,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -4083,6 +3998,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -4197,6 +4113,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -4275,6 +4192,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -4287,6 +4205,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -4308,6 +4227,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -5558,6 +5478,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5587,25 +5508,6 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@ -6029,14 +5931,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/nodemailer": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz",
"integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -6620,11 +6514,6 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@ -8238,6 +8127,7 @@
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unique-filename": {

View File

@ -8,9 +8,7 @@
"start": "next start",
"lint": "next lint",
"create-test-user": "npx ts-node -P tsconfig.scripts.json src/scripts/create-test-user.ts",
"reset-database": "npx ts-node -P tsconfig.scripts.json src/scripts/reset-database.ts",
"import-customers": "npx ts-node -P tsconfig.scripts.json src/scripts/import-customers.ts",
"send-emails": "npx ts-node -P tsconfig.scripts.json src/scripts/send-emails-to-customers.ts"
"reset-database": "npx ts-node -P tsconfig.scripts.json src/scripts/reset-database.ts"
},
"dependencies": {
"@editorjs/code": "^2.9.3",
@ -23,15 +21,10 @@
"@editorjs/marker": "^1.4.0",
"@editorjs/paragraph": "^2.11.7",
"@editorjs/quote": "^2.7.6",
"@types/axios": "^0.14.4",
"@types/nodemailer": "^6.4.17",
"axios": "^1.8.4",
"bcrypt": "^5.1.1",
"csv-parse": "^5.6.0",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.13.0",
"next": "15.2.2",
"nodemailer": "^6.10.0",
"pg": "^8.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -4,7 +4,6 @@ import { useState, useEffect, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { CONTACT_TYPES } from '@/lib/constants';
import Pagination from '@/lib/components/Pagination';
interface Customer {
id: string;
@ -20,18 +19,6 @@ interface ContactRecord {
customer: Customer;
}
interface PaginationInfo {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface ContactRecordsResponse {
data: ContactRecord[];
pagination: PaginationInfo;
}
export default function ContactRecordsList() {
const router = useRouter();
const searchParams = useSearchParams();
@ -41,43 +28,51 @@ export default function ContactRecordsList() {
const initialContactType = searchParams.get('contactType') || '';
const initialDateFrom = searchParams.get('dateFrom') || '';
const initialDateTo = searchParams.get('dateTo') || '';
const initialPage = parseInt(searchParams.get('page') || '1');
const initialPageSize = parseInt(searchParams.get('pageSize') || '10');
// State for filters
const [customerId, setCustomerId] = useState(initialCustomerId);
const [contactType, setContactType] = useState(initialContactType);
const [dateFrom, setDateFrom] = useState(initialDateFrom);
const [dateTo, setDateTo] = useState(initialDateTo);
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
// State for data
const [contactRecords, setContactRecords] = useState<ContactRecord[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
page: initialPage,
pageSize: initialPageSize,
totalCount: 0,
totalPages: 0
});
const [customers, setCustomers] = useState<Customer[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch contact records with filters and pagination
// Fetch customers for the filter dropdown
useEffect(() => {
const fetchCustomers = async () => {
try {
const response = await fetch('/api/customers');
if (!response.ok) {
throw new Error('Failed to fetch customers');
}
const data = await response.json();
setCustomers(data);
} catch (err) {
console.error('Error fetching customers:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
fetchCustomers();
}, []);
// Fetch contact records with filters
useEffect(() => {
const fetchContactRecords = async () => {
setIsLoading(true);
setError(null);
try {
// Build query string with filters and pagination
// Build query string with filters
const params = new URLSearchParams();
if (customerId) params.append('customerId', customerId);
if (contactType) params.append('contactType', contactType);
if (dateFrom) params.append('dateFrom', dateFrom);
if (dateTo) params.append('dateTo', dateTo);
params.append('page', initialPage.toString());
params.append('pageSize', initialPageSize.toString());
const response = await fetch(`/api/contact-records?${params.toString()}`);
@ -85,9 +80,8 @@ export default function ContactRecordsList() {
throw new Error('Failed to fetch contact records');
}
const responseData: ContactRecordsResponse = await response.json();
setContactRecords(responseData.data);
setPagination(responseData.pagination);
const data = await response.json();
setContactRecords(data);
} catch (err) {
console.error('Error fetching contact records:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
@ -96,21 +90,22 @@ export default function ContactRecordsList() {
}
};
// Only fetch if we have URL parameters or if this is the initial load
if (initialCustomerId || initialContactType || initialDateFrom || initialDateTo || isLoading) {
fetchContactRecords();
}, [initialCustomerId, initialContactType, initialDateFrom, initialDateTo, initialPage, initialPageSize]);
}
}, [initialCustomerId, initialContactType, initialDateFrom, initialDateTo]);
// Handle filter form submission
const handleFilterSubmit = (e: FormEvent) => {
e.preventDefault();
// Reset to page 1 when applying new filters
// Build query string with filters
const params = new URLSearchParams();
if (customerId) params.append('customerId', customerId);
if (contactType) params.append('contactType', contactType);
if (dateFrom) params.append('dateFrom', dateFrom);
if (dateTo) params.append('dateTo', dateTo);
params.append('page', '1'); // Reset to page 1
params.append('pageSize', pageSize.toString());
// Update URL with filters
router.push(`/admin/contact-records?${params.toString()}`);
@ -122,19 +117,7 @@ export default function ContactRecordsList() {
setContactType('');
setDateFrom('');
setDateTo('');
router.push(`/admin/contact-records?page=1&pageSize=${pageSize}`);
};
// Handle page change
const handlePageChange = (newPage: number) => {
setPage(newPage);
// Build query string with current filters and new page
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
// Update URL with new page
router.push(`/admin/contact-records?${params.toString()}`);
router.push('/admin/contact-records');
};
return (
@ -153,17 +136,22 @@ export default function ContactRecordsList() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label htmlFor="customerId" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Search Customer
Customer
</label>
<input
type="text"
<select
id="customerId"
name="customerId"
value={customerId}
onChange={(e) => setCustomerId(e.target.value)}
placeholder="Search customer..."
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200"
/>
>
<option value="">All Customers</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="contactType" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
@ -320,14 +308,6 @@ export default function ContactRecordsList() {
</tbody>
</table>
</div>
{/* Pagination */}
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.totalCount}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
)}
</>

View File

@ -26,7 +26,6 @@ export default function ContactRecordList({ customerId }: ContactRecordListProps
// Function to fetch contact records
const fetchContactRecords = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/contact-records?customerId=${customerId}`);
@ -36,7 +35,7 @@ export default function ContactRecordList({ customerId }: ContactRecordListProps
}
const data = await response.json();
setContactRecords(data.data);
setContactRecords(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching contact records');
} finally {

View File

@ -9,7 +9,6 @@ interface Customer {
name: string;
url: string;
email: string;
city?: string;
createdAt: string;
modifiedAt: string;
}
@ -24,7 +23,6 @@ export default function EditCustomer({ id }: EditCustomerProps) {
name: '',
url: '',
email: '',
city: '',
});
const [isLoading, setIsLoading] = useState(!!id); // Only loading if editing
const [isSubmitting, setIsSubmitting] = useState(false);
@ -51,7 +49,6 @@ export default function EditCustomer({ id }: EditCustomerProps) {
name: customer.name,
url: customer.url || '',
email: customer.email,
city: customer.city || '',
});
setIsLoading(false);
@ -172,7 +169,7 @@ export default function EditCustomer({ id }: EditCustomerProps) {
/>
</div>
<div className="mb-4">
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
@ -188,21 +185,6 @@ export default function EditCustomer({ id }: EditCustomerProps) {
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="city">
City
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="city"
type="text"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="City (optional)"
/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"

View File

@ -91,12 +91,6 @@ export default async function CustomerDetail({ params }: { params: { id: string
)}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">City</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{customer.city || <span className="text-gray-400">-</span>}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">

View File

@ -1,152 +1,20 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { getDataSource, Customer } from '@/lib/database';
import DeleteButton from './DeleteButton';
import Pagination from '@/lib/components/Pagination';
interface Customer {
id: string;
name: string;
url: string;
email: string;
city?: string;
createdAt: string;
modifiedAt: string;
}
export default async function AdminCustomers() {
// Fetch customers from the database
const dataSource = await getDataSource();
const customerRepository = dataSource.getRepository(Customer);
interface PaginationInfo {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface CustomersResponse {
data: Customer[];
pagination: PaginationInfo;
}
export default function AdminCustomers() {
const router = useRouter();
const searchParams = useSearchParams();
// Get pagination values from URL params
const initialPage = parseInt(searchParams.get('page') || '1');
const initialPageSize = parseInt(searchParams.get('pageSize') || '10');
// State for pagination
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
// Search filters state
const [filters, setFilters] = useState({
name: '',
email: '',
url: '',
city: '',
hasEmail: false
const customers = await customerRepository.find({
order: { createdAt: 'DESC' }
});
const [debouncedFilters, setDebouncedFilters] = useState(filters);
// State for data
const [customers, setCustomers] = useState<Customer[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
page: initialPage,
pageSize: initialPageSize,
totalCount: 0,
totalPages: 0
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch customers with pagination
useEffect(() => {
const fetchCustomers = async () => {
setIsLoading(true);
setError(null);
try {
// Build query string with pagination and filters
const params = new URLSearchParams();
params.append('page', initialPage.toString());
params.append('pageSize', initialPageSize.toString());
if (debouncedFilters.name) params.append('name', debouncedFilters.name);
if (debouncedFilters.email) params.append('email', debouncedFilters.email);
if (debouncedFilters.url) params.append('url', debouncedFilters.url);
if (debouncedFilters.city) params.append('city', debouncedFilters.city);
if (debouncedFilters.hasEmail) params.append('hasEmail', 'true');
const response = await fetch(`/api/customers?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch customers');
}
const responseData: CustomersResponse = await response.json();
setCustomers(responseData.data);
setPagination(responseData.pagination);
} catch (err) {
console.error('Error fetching customers:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
fetchCustomers();
}, [initialPage, initialPageSize, debouncedFilters]);
// Debounce filter changes
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedFilters(filters);
}, 300);
return () => clearTimeout(timer);
}, [filters]);
// Handle filter changes
const handleFilterChange = (key: keyof typeof filters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
// Clear all filters
const handleClearFilters = () => {
setFilters({
name: '',
email: '',
url: '',
city: '',
hasEmail: false
});
};
// Handle page change
const handlePageChange = (newPage: number) => {
setPage(newPage);
// Build query string with new page
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
// Update URL with new page
router.push(`/admin/customers?${params.toString()}`);
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Customers</h1>
{!isLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Total: {pagination.totalCount} customer{pagination.totalCount !== 1 ? 's' : ''}
</p>
)}
</div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Customers</h1>
<Link
href="/admin/customers/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@ -155,217 +23,46 @@ export default function AdminCustomers() {
</Link>
</div>
{/* Search Filters */}
<div className="mb-6 bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Filters</h2>
<button
onClick={handleClearFilters}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear all filters
</button>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="name-filter" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Search by Name
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
type="text"
id="name-filter"
value={filters.name}
onChange={(e) => handleFilterChange('name', e.target.value)}
className="block w-full rounded-md border-gray-300 pr-10 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
placeholder="Enter name..."
/>
{filters.name && (
<button
onClick={() => handleFilterChange('name', '')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg className="h-4 w-4 text-gray-400 hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div>
<label htmlFor="email-filter" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Search by Email
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
type="text"
id="email-filter"
value={filters.email}
onChange={(e) => handleFilterChange('email', e.target.value)}
className="block w-full rounded-md border-gray-300 pr-10 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
placeholder="Enter email..."
/>
{filters.email && (
<button
onClick={() => handleFilterChange('email', '')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg className="h-4 w-4 text-gray-400 hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div>
<label htmlFor="url-filter" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Search by URL
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
type="text"
id="url-filter"
value={filters.url}
onChange={(e) => handleFilterChange('url', e.target.value)}
className="block w-full rounded-md border-gray-300 pr-10 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
placeholder="Enter URL..."
/>
{filters.url && (
<button
onClick={() => handleFilterChange('url', '')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg className="h-4 w-4 text-gray-400 hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div>
<label htmlFor="city-filter" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Search by City
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input
type="text"
id="city-filter"
value={filters.city}
onChange={(e) => handleFilterChange('city', e.target.value)}
className="block w-full rounded-md border-gray-300 pr-10 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
placeholder="Enter city..."
/>
{filters.city && (
<button
onClick={() => handleFilterChange('city', '')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg className="h-4 w-4 text-gray-400 hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Email Filter
</label>
<div className="flex items-center">
<input
type="checkbox"
id="has-email"
checked={filters.hasEmail}
onChange={(e) => handleFilterChange('hasEmail', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="has-email" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Has Email
</label>
</div>
</div>
</div>
{(filters.name || filters.email || filters.url || filters.city || filters.hasEmail) && (
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Active filters: {[
filters.name && 'Name',
filters.email && 'Email',
filters.url && 'URL',
filters.city && 'City',
filters.hasEmail && 'Has Email'
].filter(Boolean).join(', ')}
</div>
)}
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 my-4 dark:bg-red-900/20 dark:border-red-500">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">Loading customers...</p>
</div>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
ID
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Name
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
URL
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Email
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
>
City
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Modified
</th>
@ -374,61 +71,58 @@ export default function AdminCustomers() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:bg-gray-800 dark:divide-gray-700">
<tbody className="divide-y divide-gray-200 bg-white">
{customers.length > 0 ? (
customers.map((customer) => (
<tr key={customer.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-200 sm:pl-6">
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
<Link
href={`/admin/customers/detail/${customer.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
className="text-indigo-600 hover:text-indigo-900"
>
{customer.id.substring(0, 8)}...
</Link>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<Link
href={`/admin/customers/detail/${customer.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
className="text-indigo-600 hover:text-indigo-900"
>
{customer.name}
</Link>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{customer.url ? (
<a
href={customer.url.startsWith('http') ? customer.url : `https://${customer.url}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
className="text-indigo-600 hover:text-indigo-900"
>
{customer.url}
</a>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
<span className="text-gray-400">-</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<a
href={`mailto:${customer.email}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
className="text-indigo-600 hover:text-indigo-900"
>
{customer.email}
</a>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{customer.city || <span className="text-gray-400 dark:text-gray-500">-</span>}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(customer.createdAt).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(customer.modifiedAt).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/customers/edit/${customer.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-4"
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</Link>
@ -438,27 +132,17 @@ export default function AdminCustomers() {
))
) : (
<tr>
<td colSpan={8} className="py-4 pl-4 pr-3 text-sm text-gray-500 dark:text-gray-400 text-center">
<td colSpan={7} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
No customers found. Create your first customer!
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.totalCount}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,68 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface DeleteButtonProps {
templateId: string;
}
export default function DeleteButton({ templateId }: DeleteButtonProps) {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const handleDelete = async () => {
if (isDeleting) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/email-templates/${templateId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete email template');
}
// Refresh the page to show updated list
router.refresh();
} catch (error) {
console.error('Error deleting email template:', error);
alert('Failed to delete email template');
} finally {
setIsDeleting(false);
setShowConfirm(false);
}
};
return (
<>
{showConfirm ? (
<span className="inline-flex items-center">
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-900 mr-2"
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-gray-600 hover:text-gray-900"
disabled={isDeleting}
>
Cancel
</button>
</span>
) : (
<button
onClick={() => setShowConfirm(true)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
)}
</>
);
}

View File

@ -1,188 +0,0 @@
'use client';
import { useState, useEffect, FormEvent, ChangeEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
interface EmailTemplate {
id: string;
title: string;
content: string;
createdAt: string;
modifiedAt: string;
}
interface EditEmailTemplateProps {
id?: string; // Optional for new template
}
export default function EditEmailTemplate({ id }: EditEmailTemplateProps) {
const router = useRouter();
const [formData, setFormData] = useState({
title: '',
content: '',
});
const [isLoading, setIsLoading] = useState(!!id); // Only loading if editing
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch email template data if editing
useEffect(() => {
const fetchEmailTemplate = async () => {
if (!id) {
setIsLoading(false);
return;
}
try {
const response = await fetch(`/api/email-templates/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch email template');
}
const template: EmailTemplate = await response.json();
setFormData({
title: template.title,
content: template.content,
});
setIsLoading(false);
} catch (err) {
setError('Failed to load email template data');
setIsLoading(false);
}
};
fetchEmailTemplate();
}, [id]);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
// Validate form
if (!formData.title || !formData.content) {
throw new Error('Title and content are required');
}
// Determine if creating or updating
const url = id ? `/api/email-templates/${id}` : '/api/email-templates';
const method = id ? 'PUT' : 'POST';
// Submit the form
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to ${id ? 'update' : 'create'} email template`);
}
// Redirect to email templates list on success
router.push('/admin/email-templates');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="text-center py-10">Loading...</div>;
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">
{id ? 'Edit Email Template' : 'Add New Email Template'}
</h1>
<Link
href="/admin/email-templates"
className="text-indigo-600 hover:text-indigo-900"
>
Back to Email Templates
</Link>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="title">
Title
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="title"
type="text"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Email template title"
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="content">
Content
</label>
<textarea
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="content"
name="content"
value={formData.content}
onChange={handleChange}
placeholder="Email template content"
rows={10}
required
/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (id ? 'Updating...' : 'Creating...') : (id ? 'Update Template' : 'Create Template')}
</button>
<Link
href="/admin/email-templates"
className="inline-block align-baseline font-bold text-sm text-indigo-600 hover:text-indigo-800"
>
Cancel
</Link>
</div>
</form>
</div>
);
}

View File

@ -1,102 +0,0 @@
import Link from 'next/link';
import { getDataSource, EmailTemplate } from '@/lib/database';
export default async function EmailTemplateDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
// Fetch email template from the database
const dataSource = await getDataSource();
const emailTemplateRepository = dataSource.getRepository(EmailTemplate);
const emailTemplate = await emailTemplateRepository.findOne({
where: { id }
});
if (!emailTemplate) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Email Template Not Found</h1>
<Link
href="/admin/email-templates"
className="text-indigo-600 hover:text-indigo-900"
>
Back to Email Templates
</Link>
</div>
<div className="bg-red-50 border-l-4 border-red-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">The requested email template could not be found.</p>
</div>
</div>
</div>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Email Template Details</h1>
<div className="flex space-x-4">
<Link
href={`/admin/email-templates/edit/${emailTemplate.id}`}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</Link>
<Link
href="/admin/email-templates"
className="text-indigo-600 hover:text-indigo-900"
>
Back to Email Templates
</Link>
</div>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Template Information</h3>
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">ID</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{emailTemplate.id}</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Created At</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{new Date(emailTemplate.createdAt).toLocaleString()}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Modified At</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{new Date(emailTemplate.modifiedAt).toLocaleString()}
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Title</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{emailTemplate.title}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Content</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 whitespace-pre-wrap">
{emailTemplate.content}
</dd>
</div>
</dl>
</div>
</div>
</div>
);
}

View File

@ -1,6 +0,0 @@
import EditEmailTemplate from '../../components/EditEmailTemplate';
export default async function EditEmailTemplatePage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
return <EditEmailTemplate id={id} />;
}

View File

@ -1,5 +0,0 @@
import EditEmailTemplate from '../components/EditEmailTemplate';
export default function NewEmailTemplatePage() {
return <EditEmailTemplate />;
}

View File

@ -1,221 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import DeleteButton from './DeleteButton';
import Pagination from '@/lib/components/Pagination';
interface EmailTemplate {
id: string;
title: string;
content: string;
createdAt: string;
modifiedAt: string;
}
interface PaginationInfo {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface EmailTemplatesResponse {
data: EmailTemplate[];
pagination: PaginationInfo;
}
export default function AdminEmailTemplates() {
const router = useRouter();
const searchParams = useSearchParams();
// Get pagination values from URL params
const initialPage = parseInt(searchParams.get('page') || '1');
const initialPageSize = parseInt(searchParams.get('pageSize') || '10');
// State for pagination
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
// State for data
const [emailTemplates, setEmailTemplates] = useState<EmailTemplate[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
page: initialPage,
pageSize: initialPageSize,
totalCount: 0,
totalPages: 0
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch email templates with pagination
useEffect(() => {
const fetchEmailTemplates = async () => {
setIsLoading(true);
setError(null);
try {
// Build query string with pagination
const params = new URLSearchParams();
params.append('page', initialPage.toString());
params.append('pageSize', initialPageSize.toString());
const response = await fetch(`/api/email-templates?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch email templates');
}
const responseData: EmailTemplatesResponse = await response.json();
setEmailTemplates(responseData.data);
setPagination(responseData.pagination);
} catch (err) {
console.error('Error fetching email templates:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
fetchEmailTemplates();
}, [initialPage, initialPageSize]);
// Handle page change
const handlePageChange = (newPage: number) => {
setPage(newPage);
// Build query string with new page
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
// Update URL with new page
router.push(`/admin/email-templates?${params.toString()}`);
};
return (
<div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Email Templates</h1>
<Link
href="/admin/email-templates/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add New Template
</Link>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 my-4 dark:bg-red-900/20 dark:border-red-500">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">Loading email templates...</p>
</div>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
style={{ width: "60%" }}
>
Title
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
style={{ width: "10%" }}
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
style={{ width: "10%" }}
>
Modified
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6" style={{ width: "15%" }}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:bg-gray-800 dark:divide-gray-700">
{emailTemplates.length > 0 ? (
emailTemplates.map((template) => (
<tr key={template.id}>
<td className="py-4 px-3 text-sm text-gray-500 dark:text-gray-400">
<Link
href={`/admin/email-templates/detail/${template.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{template.title}
</Link>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{new Date(template.createdAt).toLocaleDateString()}
{' '}
{new Date(template.createdAt).toLocaleTimeString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{new Date(template.modifiedAt).toLocaleDateString()}
{' '}
{new Date(template.modifiedAt).toLocaleTimeString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/email-templates/edit/${template.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-4"
>
Edit
</Link>
<DeleteButton templateId={template.id} />
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 dark:text-gray-400 text-center">
No email templates found. Create your first template!
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.totalCount}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,108 +1,20 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Post, getDataSource } from '@/lib/database';
import DeleteButton from './DeleteButton';
import Pagination from '@/lib/components/Pagination';
interface User {
id: string;
username: string;
}
interface Post {
id: string;
title: string;
content: string;
createdAt: string;
modifiedAt: string;
user: User;
}
interface PaginationInfo {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface PostsResponse {
data: Post[];
pagination: PaginationInfo;
}
export default function AdminPosts() {
const router = useRouter();
const searchParams = useSearchParams();
// Get pagination values from URL params
const initialPage = parseInt(searchParams.get('page') || '1');
const initialPageSize = parseInt(searchParams.get('pageSize') || '10');
// State for pagination
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
// State for data
const [posts, setPosts] = useState<Post[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
page: initialPage,
pageSize: initialPageSize,
totalCount: 0,
totalPages: 0
export default async function AdminPosts() {
// Fetch posts from the database
const dataSource = await getDataSource();
const postRepository = dataSource.getRepository(Post);
const posts = await postRepository.find({
relations: ['user'],
order: { createdAt: 'DESC' }
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch posts with pagination
useEffect(() => {
const fetchPosts = async () => {
setIsLoading(true);
setError(null);
try {
// Build query string with pagination
const params = new URLSearchParams();
params.append('page', initialPage.toString());
params.append('pageSize', initialPageSize.toString());
const response = await fetch(`/api/posts?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
const responseData: PostsResponse = await response.json();
setPosts(responseData.data);
setPagination(responseData.pagination);
} catch (err) {
console.error('Error fetching posts:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, [initialPage, initialPageSize]);
// Handle page change
const handlePageChange = (newPage: number) => {
setPage(newPage);
// Build query string with new page
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
// Update URL with new page
router.push(`/admin/posts?${params.toString()}`);
};
return (
<div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Posts</h1>
<h1 className="text-2xl font-semibold text-gray-900">Posts</h1>
<Link
href="/admin/posts/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@ -111,56 +23,34 @@ export default function AdminPosts() {
</Link>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 my-4 dark:bg-red-900/20 dark:border-red-500">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">Loading posts...</p>
</div>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Title
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Author
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Modified
</th>
@ -169,26 +59,26 @@ export default function AdminPosts() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:bg-gray-800 dark:divide-gray-700">
<tbody className="divide-y divide-gray-200 bg-white">
{posts.length > 0 ? (
posts.map((post) => (
posts.map((post: Post) => (
<tr key={post.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-200 sm:pl-6">
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{post.title}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{post.user?.username || 'Unknown'}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(post.createdAt).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(post.modifiedAt).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/posts/edit/${post.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-4"
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</Link>
@ -198,27 +88,17 @@ export default function AdminPosts() {
))
) : (
<tr>
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 dark:text-gray-400 text-center">
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
No posts found. Create your first post!
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.totalCount}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,103 +1,22 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { getDataSource, User } from '@/lib/database';
import DeleteButton from './DeleteButton';
import Pagination from '@/lib/components/Pagination';
interface User {
id: string;
username: string;
avatar: string | null;
createdAt: string;
modifiedAt: string;
}
export default async function AdminUsers() {
// Fetch users from the database
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
interface PaginationInfo {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface UsersResponse {
data: User[];
pagination: PaginationInfo;
}
export default function AdminUsers() {
const router = useRouter();
const searchParams = useSearchParams();
// Get pagination values from URL params
const initialPage = parseInt(searchParams.get('page') || '1');
const initialPageSize = parseInt(searchParams.get('pageSize') || '10');
// State for pagination
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
// State for data
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
page: initialPage,
pageSize: initialPageSize,
totalCount: 0,
totalPages: 0
const users = await userRepository.find({
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
order: { createdAt: 'DESC' }
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch users with pagination
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
setError(null);
try {
// Build query string with pagination
const params = new URLSearchParams();
params.append('page', initialPage.toString());
params.append('pageSize', initialPageSize.toString());
const response = await fetch(`/api/users?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const responseData: UsersResponse = await response.json();
setUsers(responseData.data);
setPagination(responseData.pagination);
} catch (err) {
console.error('Error fetching users:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, [initialPage, initialPageSize]);
// Handle page change
const handlePageChange = (newPage: number) => {
setPage(newPage);
// Build query string with new page
const params = new URLSearchParams(searchParams.toString());
params.set('page', newPage.toString());
// Update URL with new page
router.push(`/admin/users?${params.toString()}`);
};
return (
<div>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
<h1 className="text-2xl font-semibold text-gray-900">Users</h1>
<Link
href="/admin/users/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@ -106,56 +25,34 @@ export default function AdminUsers() {
</Link>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 my-4 dark:bg-red-900/20 dark:border-red-500">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">Loading users...</p>
</div>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-200 sm:pl-6"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6"
>
Username
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Avatar
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Created
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-200"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
Modified
</th>
@ -164,14 +61,14 @@ export default function AdminUsers() {
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:bg-gray-800 dark:divide-gray-700">
<tbody className="divide-y divide-gray-200 bg-white">
{users.length > 0 ? (
users.map((user) => (
<tr key={user.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-200 sm:pl-6">
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{user.username}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{user.avatar ? (
<Image
src={user.avatar}
@ -181,21 +78,21 @@ export default function AdminUsers() {
className="rounded-full"
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-gray-500 dark:text-gray-300">
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500">
{user.username.charAt(0).toUpperCase()}
</div>
)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(user.modifiedAt).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<Link
href={`/admin/users/edit/${user.id}`}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-4"
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</Link>
@ -205,27 +102,17 @@ export default function AdminUsers() {
))
) : (
<tr>
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 dark:text-gray-400 text-center">
<td colSpan={5} className="py-4 pl-4 pr-3 text-sm text-gray-500 text-center">
No users found. Create your first user!
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.totalCount}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -7,7 +7,6 @@ export async function GET(request: NextRequest) {
const dataSource = await getDataSource();
const contactRecordRepository = dataSource.getRepository(ContactRecord);
// Get query parameters
const url = new URL(request.url);
const customerId = url.searchParams.get('customerId');
@ -15,63 +14,49 @@ export async function GET(request: NextRequest) {
const dateFrom = url.searchParams.get('dateFrom');
const dateTo = url.searchParams.get('dateTo');
// Pagination parameters
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const skip = (page - 1) * pageSize;
// Build query
let queryBuilder = contactRecordRepository.createQueryBuilder('contactRecord')
.leftJoinAndSelect('contactRecord.customer', 'customer')
.orderBy('contactRecord.createdAt', 'DESC');
const queryOptions: any = {
order: { createdAt: 'DESC' },
relations: ['customer']
};
// Apply filters
// Build where clause
let whereClause: any = {};
// Filter by customer if customerId is provided
if (customerId) {
queryBuilder = queryBuilder.andWhere('contactRecord.customerId = :customerId', { customerId });
whereClause.customerId = customerId;
}
// Filter by contact type if provided
if (contactType) {
queryBuilder = queryBuilder.andWhere('contactRecord.contactType = :contactType', { contactType });
whereClause.contactType = contactType;
}
// Filter by date range if provided
if (dateFrom || dateTo) {
whereClause.createdAt = {};
if (dateFrom) {
queryBuilder = queryBuilder.andWhere('contactRecord.createdAt >= :dateFrom', {
dateFrom: new Date(dateFrom)
});
whereClause.createdAt.gte = new Date(dateFrom);
}
if (dateTo) {
// Set the date to the end of the day for inclusive filtering
const endDate = new Date(dateTo);
endDate.setHours(23, 59, 59, 999);
queryBuilder = queryBuilder.andWhere('contactRecord.createdAt <= :dateTo', {
dateTo: endDate
});
whereClause.createdAt.lte = endDate;
}
}
// Get total count for pagination
const totalCount = await queryBuilder.getCount();
// Apply pagination
queryBuilder = queryBuilder
.skip(skip)
.take(pageSize);
// Execute query
const contactRecords = await queryBuilder.getMany();
// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);
return NextResponse.json({
data: contactRecords,
pagination: {
page,
pageSize,
totalCount,
totalPages
// Add where clause to query options if not empty
if (Object.keys(whereClause).length > 0) {
queryOptions.where = whereClause;
}
});
const contactRecords = await contactRecordRepository.find(queryOptions);
return NextResponse.json(contactRecords);
} catch (error) {
console.error('Error fetching contact records:', error);
return NextResponse.json(

View File

@ -55,7 +55,7 @@ export async function PUT(
}
const data = await request.json();
const { name, url, email, city } = data;
const { name, url, email } = data;
// Validate required fields
if (!name || !email) {
@ -69,7 +69,6 @@ export async function PUT(
customer.name = name;
customer.url = url || '';
customer.email = email;
customer.city = city || null;
// Save the updated customer
const updatedCustomer = await customerRepository.save(customer);

View File

@ -7,67 +7,11 @@ export async function GET(request: NextRequest) {
const dataSource = await getDataSource();
const customerRepository = dataSource.getRepository(Customer);
// Get query parameters
const url = new URL(request.url);
// Pagination parameters
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const skip = (page - 1) * pageSize;
// Get filter parameters
const nameFilter = url.searchParams.get('name');
const emailFilter = url.searchParams.get('email');
const urlFilter = url.searchParams.get('url');
const cityFilter = url.searchParams.get('city');
const hasEmailFilter = url.searchParams.get('hasEmail');
// Build query
let queryBuilder = customerRepository.createQueryBuilder('customer');
// Apply filters
if (nameFilter) {
queryBuilder = queryBuilder.andWhere('LOWER(customer.name) LIKE LOWER(:name)', { name: `%${nameFilter}%` });
}
if (emailFilter) {
queryBuilder = queryBuilder.andWhere('LOWER(customer.email) LIKE LOWER(:email)', { email: `%${emailFilter}%` });
}
if (urlFilter) {
queryBuilder = queryBuilder.andWhere('LOWER(customer.url) LIKE LOWER(:url)', { url: `%${urlFilter}%` });
}
if (cityFilter) {
queryBuilder = queryBuilder.andWhere('LOWER(customer.city) LIKE LOWER(:city)', { city: `%${cityFilter}%` });
}
if (hasEmailFilter === 'true') {
queryBuilder = queryBuilder.andWhere('customer.email IS NOT NULL AND customer.email != :emptyString', { emptyString: '' });
}
// Add ordering
queryBuilder = queryBuilder.orderBy('customer.createdAt', 'DESC');
// Get total count for pagination
const totalCount = await queryBuilder.getCount();
// Apply pagination
queryBuilder = queryBuilder
.skip(skip)
.take(pageSize);
// Execute query
const customers = await queryBuilder.getMany();
// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);
return NextResponse.json({
data: customers,
pagination: {
page,
pageSize,
totalCount,
totalPages
}
const customers = await customerRepository.find({
order: { createdAt: 'DESC' }
});
return NextResponse.json(customers);
} catch (error) {
console.error('Error fetching customers:', error);
return NextResponse.json(
@ -84,7 +28,7 @@ export async function POST(request: NextRequest) {
const customerRepository = dataSource.getRepository(Customer);
const data = await request.json();
const { name, url, email, city } = data;
const { name, url, email } = data;
// Validate required fields
if (!name || !email) {
@ -99,7 +43,6 @@ export async function POST(request: NextRequest) {
customer.name = name;
customer.url = url || '';
customer.email = email;
customer.city = city || null;
const savedCustomer = await customerRepository.save(customer);

View File

@ -1,18 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDataSource, EmailTemplate } from '@/lib/database';
// GET /api/email-templates/[id] - Get a specific email template
// GET /api/email-templates/[id] - Get a single email template by ID
export async function GET(
request: NextRequest,
props: { params: Promise<{ id: string }> }
{ params }: { params: { id: string } }
) {
try {
const { id } = await props.params;
const { id } = params;
const dataSource = await getDataSource();
const emailTemplateRepository = dataSource.getRepository(EmailTemplate);
const emailTemplate = await emailTemplateRepository.findOne({
where: { id: id }
where: { id }
});
if (!emailTemplate) {
@ -35,16 +35,16 @@ export async function GET(
// PUT /api/email-templates/[id] - Update an email template
export async function PUT(
request: NextRequest,
props: { params: Promise<{ id: string }> }
{ params }: { params: { id: string } }
) {
try {
const { id } = await props.params;
const { id } = params;
const dataSource = await getDataSource();
const emailTemplateRepository = dataSource.getRepository(EmailTemplate);
// Find the email template to update
// Check if email template exists
const emailTemplate = await emailTemplateRepository.findOne({
where: { id: id }
where: { id }
});
if (!emailTemplate) {
@ -54,22 +54,23 @@ export async function PUT(
);
}
// Get update data
const data = await request.json();
const { title, content } = data;
const { name, content } = data;
// Validate required fields
if (!title || !content) {
if (!name && !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ error: 'At least one field (name or content) must be provided' },
{ status: 400 }
);
}
// Update email template fields
emailTemplate.title = title;
emailTemplate.content = content;
// Update fields
if (name) emailTemplate.name = name;
if (content) emailTemplate.content = content;
// Save the updated email template
// Save updated email template
const updatedEmailTemplate = await emailTemplateRepository.save(emailTemplate);
return NextResponse.json(updatedEmailTemplate);
@ -85,16 +86,16 @@ export async function PUT(
// DELETE /api/email-templates/[id] - Delete an email template
export async function DELETE(
request: NextRequest,
props: { params: Promise<{ id: string }> }
{ params }: { params: { id: string } }
) {
try {
const { id } = await props.params;
const { id } = params;
const dataSource = await getDataSource();
const emailTemplateRepository = dataSource.getRepository(EmailTemplate);
// Find the email template to delete
// Check if email template exists
const emailTemplate = await emailTemplateRepository.findOne({
where: { id: id }
where: { id }
});
if (!emailTemplate) {
@ -107,7 +108,10 @@ export async function DELETE(
// Delete the email template
await emailTemplateRepository.remove(emailTemplate);
return NextResponse.json({ success: true });
return NextResponse.json(
{ message: 'Email template deleted successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error deleting email template:', error);
return NextResponse.json(

View File

@ -9,39 +9,23 @@ export async function GET(request: NextRequest) {
// Get query parameters
const url = new URL(request.url);
// Pagination parameters
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const skip = (page - 1) * pageSize;
const search = url.searchParams.get('search');
// Build query
let queryBuilder = emailTemplateRepository.createQueryBuilder('emailTemplate')
.orderBy('emailTemplate.createdAt', 'DESC');
const queryOptions: any = {
order: { createdAt: 'DESC' }
};
// Get total count for pagination
const totalCount = await queryBuilder.getCount();
// Apply pagination
queryBuilder = queryBuilder
.skip(skip)
.take(pageSize);
// Execute query
const emailTemplates = await queryBuilder.getMany();
// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);
return NextResponse.json({
data: emailTemplates,
pagination: {
page,
pageSize,
totalCount,
totalPages
// Add search filter if provided
if (search) {
queryOptions.where = [
{ name: search ? { contains: search } : undefined }
];
}
});
const emailTemplates = await emailTemplateRepository.find(queryOptions);
return NextResponse.json(emailTemplates);
} catch (error) {
console.error('Error fetching email templates:', error);
return NextResponse.json(
@ -58,19 +42,19 @@ export async function POST(request: NextRequest) {
const emailTemplateRepository = dataSource.getRepository(EmailTemplate);
const data = await request.json();
const { title, content } = data;
const { name, content } = data;
// Validate required fields
if (!title || !content) {
if (!name || !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ error: 'Name and content are required' },
{ status: 400 }
);
}
// Create and save the new email template
const emailTemplate = new EmailTemplate();
emailTemplate.title = title;
emailTemplate.name = name;
emailTemplate.content = content;
const savedEmailTemplate = await emailTemplateRepository.save(emailTemplate);

View File

@ -11,13 +11,8 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const parentId = url.searchParams.get('parentId');
// Pagination parameters
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const skip = (page - 1) * pageSize;
// Build query
let queryBuilder = postRepository.createQueryBuilder('post')
let query = postRepository.createQueryBuilder('post')
.leftJoinAndSelect('post.user', 'user')
.leftJoinAndSelect('post.parent', 'parent')
.orderBy('post.createdAt', 'DESC');
@ -26,36 +21,16 @@ export async function GET(request: NextRequest) {
if (parentId) {
if (parentId === 'null') {
// Get root posts (no parent)
queryBuilder = queryBuilder.where('post.parentId IS NULL');
query = query.where('post.parentId IS NULL');
} else {
// Get children of specific parent
queryBuilder = queryBuilder.where('post.parentId = :parentId', { parentId });
query = query.where('post.parentId = :parentId', { parentId });
}
}
// Get total count for pagination
const totalCount = await queryBuilder.getCount();
const posts = await query.getMany();
// Apply pagination
queryBuilder = queryBuilder
.skip(skip)
.take(pageSize);
// Execute query
const posts = await queryBuilder.getMany();
// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);
return NextResponse.json({
data: posts,
pagination: {
page,
pageSize,
totalCount,
totalPages
}
});
return NextResponse.json(posts);
} catch (error) {
console.error('Error fetching posts:', error);
return NextResponse.json(

View File

@ -9,42 +9,12 @@ export async function GET(request: NextRequest) {
const dataSource = await getDataSource();
const userRepository = dataSource.getRepository(User);
// Get query parameters
const url = new URL(request.url);
// Pagination parameters
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
const skip = (page - 1) * pageSize;
// Build query
let queryBuilder = userRepository.createQueryBuilder('user')
.select(['user.id', 'user.username', 'user.avatar', 'user.createdAt', 'user.modifiedAt'])
.orderBy('user.createdAt', 'DESC');
// Get total count for pagination
const totalCount = await queryBuilder.getCount();
// Apply pagination
queryBuilder = queryBuilder
.skip(skip)
.take(pageSize);
// Execute query
const users = await queryBuilder.getMany();
// Calculate total pages
const totalPages = Math.ceil(totalCount / pageSize);
return NextResponse.json({
data: users,
pagination: {
page,
pageSize,
totalCount,
totalPages
}
const users = await userRepository.find({
select: ['id', 'username', 'avatar', 'createdAt', 'modifiedAt'],
order: { createdAt: 'DESC' }
});
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json(

View File

@ -1,143 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
onPageChange?: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
onPageChange
}: PaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Calculate the range of items being displayed
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
// Generate page numbers to display
const getPageNumbers = () => {
const pages = [];
const maxPagesToShow = 5; // Show at most 5 page numbers
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = startPage + maxPagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
// Handle page change
const handlePageChange = (page: number) => {
if (page < 1 || page > totalPages) return;
if (onPageChange) {
onPageChange(page);
} else {
// Update URL with new page parameter
const params = new URLSearchParams(searchParams.toString());
params.set('page', page.toString());
router.push(`?${params.toString()}`);
}
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 dark:bg-gray-800 dark:border-gray-700">
<div className="flex flex-1 justify-between sm:hidden">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 ${currentPage === 1
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
Previous
</button>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 ${currentPage === totalPages
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
Next
</button>
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{startItem}</span> to{' '}
<span className="font-medium">{endItem}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
</div>
<div>
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 ${currentPage === 1
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0'
}`}
>
<span className="sr-only">Previous</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clipRule="evenodd" />
</svg>
</button>
{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => handlePageChange(page)}
aria-current={page === currentPage ? 'page' : undefined}
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${page === currentPage
? 'z-10 bg-indigo-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-700'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 ${currentPage === totalPages
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0'
}`}
>
<span className="sr-only">Next</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
);
}

View File

@ -15,9 +15,6 @@ export class Customer {
@Column()
email: string;
@Column({ nullable: true })
city: string;
@CreateDateColumn()
createdAt: Date;

View File

@ -6,7 +6,7 @@ export class EmailTemplate {
id: string;
@Column()
title: string;
name: string;
@Column('text')
content: string;

View File

@ -1,91 +0,0 @@
import nodemailer from 'nodemailer';
import { ContactRecord } from './database/entities/ContactRecord';
import { Customer } from './database/entities/Customer';
import { getDataSource } from './database';
interface SendEmailResult {
success: boolean;
error?: string;
}
export async function sendEmail(
customerId: string,
subject: string,
body: string,
htmlBody?: string
): Promise<SendEmailResult> {
try {
// Get data source
const dataSource = await getDataSource();
// Get customer details
const customer = await dataSource.getRepository(Customer).findOne({
where: { id: customerId }
});
if (!customer) {
throw new Error(`Customer with ID ${customerId} not found`);
}
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587, // TLS port
secure: false, // true for 465 (SSL), false for other ports like 587 (TLS)
auth: {
user: process.env.GMAIL_USER || 'ken@catsai-agency.com',
pass: process.env.GMAIL_APP_PASSWORD || 'xkptiigonuhxkgma', // "xkpt iigo nuhx kgma"
},
tls: {
// Do not fail on invalid certificates
rejectUnauthorized: false
}
});
// Send email
const info = await transporter.sendMail({
from: process.env.GMAIL_USER || 'ken@catsai-agency.com',
to: customer.email,
subject: subject,
text: body
});
// Create contact record
const contactRecord = new ContactRecord();
contactRecord.customer = customer;
contactRecord.customerId = customer.id;
contactRecord.contactType = 'EMAIL';
contactRecord.notes = `Email sent successfully. Subject: ${subject}. Body: ${body}`;
// Save contact record
await dataSource.getRepository(ContactRecord).save(contactRecord);
return { success: true };
} catch (error) {
// Create contact record for failed attempt
if (error instanceof Error) {
try {
const dataSource = await getDataSource();
const customer = await dataSource.getRepository(Customer).findOne({
where: { id: customerId }
});
if (customer) {
const contactRecord = new ContactRecord();
contactRecord.customer = customer;
contactRecord.customerId = customer.id;
contactRecord.contactType = 'EMAIL';
contactRecord.notes = `Failed to send email: ${error.message}`;
await dataSource.getRepository(ContactRecord).save(contactRecord);
}
} catch (dbError) {
console.error('Failed to save error record:', dbError);
}
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}

View File

@ -1,127 +0,0 @@
import 'reflect-metadata';
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
import { getDataSource } from '../lib/database';
import { Customer } from '../lib/database/entities/Customer';
interface CustomerCSVRow {
City: string;
Name: string;
URL: string;
Email: string;
}
async function importCustomers(csvFilePath: string): Promise<void> {
try {
// Initialize database connection
const dataSource = await getDataSource();
const customerRepository = dataSource.getRepository(Customer);
// Read and parse CSV file
const fileContent = fs.readFileSync(csvFilePath, 'utf-8');
const records = parse(fileContent, {
columns: true,
skip_empty_lines: true,
trim: true,
}) as CustomerCSVRow[];
console.log(`Found ${records.length} records in CSV file`);
// Get existing customers for update
const existingCustomers = await customerRepository.find();
const customersByEmail = new Map<string, Customer>();
const customersByName = new Map<string, Customer>();
// Create lookup maps for faster access
existingCustomers.forEach(customer => {
if (customer.email) {
customersByEmail.set(customer.email.toLowerCase(), customer);
}
customersByName.set(customer.name.toLowerCase(), customer);
});
let importedCount = 0;
let updatedCount = 0;
for (const record of records) {
const email = record.Email === 'null' ? '' : record.Email;
const name = record.Name;
let customer: Customer;
let isUpdate = false;
// Check if customer exists by email or name
if (email && customersByEmail.has(email.toLowerCase())) {
// Update existing customer by email
customer = customersByEmail.get(email.toLowerCase())!;
isUpdate = true;
console.log(`Updating customer with email: ${email}`);
} else if (customersByName.has(name.toLowerCase())) {
// Update existing customer by name
customer = customersByName.get(name.toLowerCase())!;
isUpdate = true;
console.log(`Updating customer with name: ${name}`);
} else {
// Create new customer
customer = new Customer();
console.log(`Creating new customer: ${name}`);
}
// Update customer fields
customer.name = name;
customer.url = record.URL === 'null' ? '' : record.URL;
customer.email = email;
customer.city = record.City === 'null' ? '' : record.City;
try {
// Save to database
await customerRepository.save(customer);
if (isUpdate) {
updatedCount++;
console.log(`Updated customer: ${name}`);
} else {
importedCount++;
console.log(`Imported customer: ${name}`);
// Add to lookup maps for future reference
if (email) {
customersByEmail.set(email.toLowerCase(), customer);
}
customersByName.set(name.toLowerCase(), customer);
}
} catch (e) {
console.log(`Error saving customer: ${name}`, e);
}
}
console.log('Import summary:');
console.log(`- Total records in CSV: ${records.length}`);
console.log(`- Successfully imported (new): ${importedCount}`);
console.log(`- Successfully updated: ${updatedCount}`);
} catch (error) {
console.error('Error importing customers:', error);
throw error;
}
}
// Check if file path is provided as command line argument
const csvFilePath = process.argv[2];
if (!csvFilePath) {
console.error('Please provide the path to the CSV file as a command line argument');
console.error('Example: npm run import-customers -- ./data/customers.csv');
process.exit(1);
}
// Run the import function
importCustomers(csvFilePath)
.then(() => {
console.log('Import completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Import failed:', error);
process.exit(1);
});

View File

@ -1,270 +0,0 @@
import 'reflect-metadata';
import { getDataSource } from '../lib/database';
import { Customer } from '../lib/database/entities/Customer';
import { ContactRecord } from '../lib/database/entities/ContactRecord';
import { sendEmail } from '../lib/email';
import axios from 'axios';
// Get command line arguments
const args = process.argv.slice(2);
const emailCountArg = args.find(arg => arg.startsWith('--count='));
const lmStudioUrlArg = args.find(arg => arg.startsWith('--lmstudio-url='));
const modelArg = args.find(arg => arg.startsWith('--model='));
const temperatureArg = args.find(arg => arg.startsWith('--temperature='));
const dryRunArg = args.find(arg => arg.startsWith('--dry-run='));
// Parse arguments with defaults
const defaultEmailCount = 0;
const emailCount = emailCountArg
? parseInt(emailCountArg.split('=')[1], 10)
: defaultEmailCount;
// LMStudio API settings
const lmStudioUrl = lmStudioUrlArg
? lmStudioUrlArg.split('=')[1]
: 'http://localhost:1234/v1/chat/completions'; // Default LMStudio API endpoint
const model = modelArg
? modelArg.split('=')[1]
: 'local-model'; // Default model name
const temperature = temperatureArg
? parseFloat(temperatureArg.split('=')[0.3])
: 0.7; // Default temperature
// Dry run mode - generate content but don't send emails or create records
const dryRun = dryRunArg
? dryRunArg.split('=')[1].toLowerCase() === 'true'
: false;
if (isNaN(emailCount) || emailCount < 0) {
console.error('Error: Email count must be a non-negative number');
process.exit(1);
}
// Function to generate email content using LMStudio API
async function generateEmailContent(
customer: Customer,
emailCount: number
): Promise<{ subject: string; body: string }> {
try {
console.log(`Generating email content for ${customer.name} using LMStudio API...`);
// Create a prompt based on customer info and email count
let prompt = '';
if (emailCount === 0) {
prompt = `Please generate cold email to promote custom generative AI video service.
Customer name : ${customer.name}
Customer city: ${customer.city}
Give me in following format { subject: "Short subject in local language" body: "Short email body in local language in plain text format" }
Please start with like "Hi I'm Ken from Cat's AI Ltd"
Please make short proposal how generative AI video can help to the museum
Please say something specific for the museum or the city
Subject should be something like "AI video for museums". 10 words max.
Please add in the end this email is generated by AI so please forgive me if something is wrong.
Please be short and clear
Please translate to local language.
Please include these sample videos to the email
https://www.youtube.com/watch?v=bfYzRBJEI7A
https://www.youtube.com/watch?v=XLI1oyy3TF0
https://www.youtube.com/watch?v=fX4BnZaDCxg`;
}/* else if (emailCount === 1) {
prompt = `Generate a follow-up email for customer ${customer.name} from ${customer.url}.
This is the second contact with them. The email should reference a previous welcome email and provide more value.
Return ONLY a JSON object with 'subject' and 'body' fields. The body should be in HTML format.`;
} else {
prompt = `Generate a relationship-building email for a regular customer named ${customer.name} from ${customer.url}.
This is contact number ${emailCount + 1} with them. The email should be personalized and provide specific value.
Return ONLY a JSON object with 'subject' and 'body' fields. The body should be in HTML format.`;
} */
// Call LMStudio API
const response = await axios.post(
lmStudioUrl,
{
model: model,
messages: [
{ role: 'system', content: 'You are an expert email copywriter who creates engaging, professional emails. Output should be only JSON' },
{ role: 'user', content: prompt }
],
temperature: temperature,
max_tokens: 500
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
// Parse the response to extract the JSON
const assistantMessage = response.data.choices[0].message.content;
// Try to extract JSON from the response
try {
// Look for JSON object in the response
const jsonMatch = assistantMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const jsonStr = jsonMatch[0];
const emailContent = JSON.parse(jsonStr);
if (emailContent.subject && emailContent.body) {
return {
subject: emailContent.subject,
body: emailContent.body
};
}
}
// If we couldn't parse JSON or it doesn't have required fields
throw new Error('Could not parse valid JSON from LMStudio response');
} catch (parseError) {
console.warn('Failed to parse JSON from LMStudio response, using fallback content');
console.warn('LMStudio response:', assistantMessage);
// Fallback content
return {
subject: `Enhance Engagement with AI-Powered Video`,
body: `Hi\n\nI'm Ken from Cat's AI Ltd.\n\nWe help museums like yours create engaging video content using cutting-edge generative AI. Imagine bringing historical artifacts to life, creating immersive virtual tours, or developing captivating educational videos all without extensive production costs.\n\nGiven the rich cultural landscape of the USA and Default Museums important role within it, we believe AI video could significantly boost visitor engagement and online reach.\n\nHere are a few examples of what's possible:\nhttps://www.youtube.com/watch?v=bfYzRBJEI7A\nhttps://www.youtube.com/watch?v=XLI1oyy3TF0\nhttps://www.youtube.com/watch?v=fX4BnZaDCxg\n\nWould you be open to a quick chat about how AI video can benefit Default Museum?\n\nBest regards,\nKen (Cat's AI Ltd)`
};
}
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error occurred';
console.error('Error calling LMStudio API:', errorMessage);
// Fallback content in case of API error
return {
subject: `Enhance Engagement with AI-Powered Video`,
body: `Hi\n\nI'm Ken from Cat's AI Ltd.\n\nWe help museums like yours create engaging video content using cutting-edge generative AI. Imagine bringing historical artifacts to life, creating immersive virtual tours, or developing captivating educational videos all without extensive production costs.\n\nGiven the rich cultural landscape of the USA and Default Museums important role within it, we believe AI video could significantly boost visitor engagement and online reach.\n\nHere are a few examples of what's possible:\nhttps://www.youtube.com/watch?v=bfYzRBJEI7A\nhttps://www.youtube.com/watch?v=XLI1oyy3TF0\nhttps://www.youtube.com/watch?v=fX4BnZaDCxg\n\nWould you be open to a quick chat about how AI video can benefit Default Museum?\n\nBest regards,\nKen (Cat's AI Ltd)`
};
}
}
async function main() {
try {
console.log('Initializing database connection...');
const dataSource = await getDataSource();
console.log(`Finding customers with exactly ${emailCount} email contact records...`);
let customersQuery = dataSource
.getRepository(Customer)
.createQueryBuilder('customer');
if (emailCount === 0) {
// Find customers with no email contact records
customersQuery = customersQuery
.leftJoin(
ContactRecord,
'contact',
'contact.customerId = customer.id AND contact.contactType = :contactType',
{ contactType: 'EMAIL' }
)
.where('contact.id IS NULL')
.andWhere('customer.email IS NOT NULL')
.andWhere('customer.email != :emptyEmail', { emptyEmail: '' })
.limit(100);
} else {
// Find customers with exactly N email contact records
customersQuery = customersQuery
.leftJoin(
ContactRecord,
'contact',
'contact.customerId = customer.id AND contact.contactType = :contactType',
{ contactType: 'EMAIL' }
)
.where('customer.email IS NOT NULL')
.andWhere('customer.email != :emptyEmail', { emptyEmail: '' })
.groupBy('customer.id')
.having('COUNT(contact.id) = :count', { count: emailCount })
.limit(100);
}
const filteredCustomers = await customersQuery.getMany();
console.log(`Found ${filteredCustomers.length} customers with exactly ${emailCount} email contact records.`);
// Exit if no customers found
if (filteredCustomers.length === 0) {
console.log(`No customers with exactly ${emailCount} email contact records found. Exiting.`);
await dataSource.destroy();
return;
}
// Ask for confirmation before sending emails
console.log('The following customers will receive emails:');
filteredCustomers.forEach((customer, index) => {
console.log(`${index + 1}. ${customer.name} (${customer.email})`);
});
console.log('\nSending emails to customers...');
// Send emails to each customer
for (const customer of filteredCustomers) {
console.log(`Processing email for ${customer.name} (${customer.email})...`);
try {
// Generate email content using LMStudio
const emailContent = await generateEmailContent(customer, emailCount);
console.log(`Generated subject: ${emailContent.subject}`);
if (dryRun) {
console.log(`[DRY RUN] Would send email to ${customer.name} (${customer.email})`);
console.log(`[DRY RUN] Subject: ${emailContent.subject}`);
console.log(`[DRY RUN] Body: ${emailContent.body.replace(/<[^>]*>/g, '')}`);
console.log(`[DRY RUN] No email sent and no contact record created.`);
} else {
console.log(`Sending email to ${customer.name}...`);
const result = await sendEmail(
customer.id,
emailContent.subject,
emailContent.body.replace(/<[^>]*>/g, ''), // Plain text version (strip HTML)
);
if (result.success) {
console.log(`✅ Email sent successfully to ${customer.email}`);
} else {
console.error(`❌ Failed to send email to ${customer.email}: ${result.error}`);
}
}
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error occurred';
console.error(`❌ Error sending email to ${customer.email}:`, errorMessage);
}
// Add a small delay between emails to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log('Email sending process completed.');
await dataSource.destroy();
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error occurred';
console.error('An error occurred:', errorMessage);
process.exit(1);
}
}
// Run the main function
main().catch(error => {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error occurred';
console.error('Unhandled error:', errorMessage);
process.exit(1);
});