# This is a combination of 2 commits.
# This is the 1st commit message: first commit # This is the commit message #2: fixed cache
This commit is contained in:
commit
1fd7dad8e4
81 changed files with 13210 additions and 0 deletions
5
.env.local
Normal file
5
.env.local
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
LISTMONK_URL=http://localhost:9000
|
||||
NEXT_APP_URL=http://localhost:3000
|
||||
BASIC_AUTH_USERNAME=username
|
||||
BASIC_AUTH_PASSWORD=password
|
||||
API_KEY=your-api-key
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM golang:1.17-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
EXPOSE 8808
|
||||
|
||||
CMD ["./main"]
|
||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# ghost-listmonk-connector
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
version: '3'
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8808:8808"
|
||||
environment:
|
||||
- PORT=8808
|
||||
- LISTMONK_URL=http://listmonk:9000
|
||||
- BASIC_AUTH_USERNAME=admin
|
||||
- BASIC_AUTH_PASSWORD=password
|
||||
depends_on:
|
||||
- listmonk
|
||||
|
||||
listmonk:
|
||||
image: listmonk/listmonk:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
- APP_DB_HOST=db
|
||||
- APP_DB_USER=listmonk
|
||||
- APP_DB_PASSWORD=listmonk
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: postgres:13
|
||||
environment:
|
||||
- POSTGRES_USER=listmonk
|
||||
- POSTGRES_PASSWORD=listmonk
|
||||
- POSTGRES_DB=listmonk
|
||||
volumes:
|
||||
- listmonk-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
listmonk-data:
|
||||
BIN
ghost_listmonk.db
Normal file
BIN
ghost_listmonk.db
Normal file
Binary file not shown.
43
go.mod
Normal file
43
go.mod
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
module github.com/troneras/ghost-listmonk-connector
|
||||
|
||||
go 1.21.6
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/stretchr/testify v1.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.12.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.9.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
102
go.sum
Normal file
102
go.sum
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
|
||||
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
|
||||
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
139
handlers/son_handlers.go
Normal file
139
handlers/son_handlers.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
"github.com/troneras/ghost-listmonk-connector/services"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
type SonHandler struct {
|
||||
storage *services.SonStorage
|
||||
}
|
||||
|
||||
type ListmonkHandler struct {
|
||||
client *services.ListmonkClient
|
||||
}
|
||||
|
||||
func NewSonHandler(storage *services.SonStorage) *SonHandler {
|
||||
return &SonHandler{storage: storage}
|
||||
}
|
||||
|
||||
func NewListmonkHandler(client *services.ListmonkClient) *ListmonkHandler {
|
||||
return &ListmonkHandler{client: client}
|
||||
}
|
||||
|
||||
func (h *ListmonkHandler) GetLists(c *gin.Context) {
|
||||
lists, err := h.client.GetLists()
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to get lists: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": lists})
|
||||
}
|
||||
|
||||
func (h *ListmonkHandler) GetTemplates(c *gin.Context) {
|
||||
templates, err := h.client.GetTemplates()
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to get templates: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": templates})
|
||||
}
|
||||
|
||||
func (h *SonHandler) Create(c *gin.Context) {
|
||||
var son models.Son
|
||||
if err := c.ShouldBindJSON(&son); err != nil {
|
||||
utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.storage.Create(&son); err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Created Son: %s", utils.PrettyPrint(son))
|
||||
c.JSON(http.StatusCreated, son)
|
||||
}
|
||||
|
||||
func (h *SonHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
son, err := h.storage.Get(id)
|
||||
if err != nil {
|
||||
if err == services.ErrSonNotFound {
|
||||
utils.ErrorLogger.Errorf("Son not found: %s", id)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
|
||||
} else {
|
||||
utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Retrieved Son: %s", utils.PrettyPrint(son))
|
||||
c.JSON(http.StatusOK, son)
|
||||
}
|
||||
|
||||
func (h *SonHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var son models.Son
|
||||
if err := c.ShouldBindJSON(&son); err != nil {
|
||||
utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
son.ID = id
|
||||
if err := h.storage.Update(son); err != nil {
|
||||
if err == services.ErrSonNotFound {
|
||||
utils.ErrorLogger.Errorf("Son not found for update: %s", id)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
|
||||
} else {
|
||||
utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Updated Son: %s", utils.PrettyPrint(son))
|
||||
c.JSON(http.StatusOK, son)
|
||||
}
|
||||
|
||||
func (h *SonHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.storage.Delete(id); err != nil {
|
||||
if err == services.ErrSonNotFound {
|
||||
utils.ErrorLogger.Errorf("Son not found for deletion: %s", id)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
|
||||
} else {
|
||||
utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Deleted Son: %s", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Son deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *SonHandler) List(c *gin.Context) {
|
||||
sons, err := h.storage.List()
|
||||
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Retrieved %d Sons", len(sons))
|
||||
c.JSON(http.StatusOK, sons)
|
||||
}
|
||||
97
handlers/webhook_handler.go
Normal file
97
handlers/webhook_handler.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
"github.com/troneras/ghost-listmonk-connector/services"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
type WebhookHandler struct {
|
||||
sonStorage *services.SonStorage
|
||||
executor *services.SonExecutor
|
||||
}
|
||||
|
||||
func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor) *WebhookHandler {
|
||||
return &WebhookHandler{
|
||||
sonStorage: sonStorage,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
|
||||
var webhookData map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&webhookData); err != nil {
|
||||
utils.ErrorLogger.Errorf("Invalid webhook data: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"})
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Received webhook: %s", utils.PrettyPrint(webhookData))
|
||||
|
||||
triggerType, err := determineTriggerType(webhookData)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Unable to determine trigger type: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"})
|
||||
return
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Determined trigger type: %s", triggerType)
|
||||
|
||||
// Find and execute relevant Sons
|
||||
sons, err := h.sonStorage.List()
|
||||
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"})
|
||||
return
|
||||
}
|
||||
|
||||
executedCount := 0
|
||||
for _, son := range sons {
|
||||
if son.Trigger == triggerType {
|
||||
go func(s models.Son) {
|
||||
utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType)
|
||||
h.executor.ExecuteSon(s, webhookData)
|
||||
}(son)
|
||||
executedCount++
|
||||
}
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Executed %d Sons for trigger %s", executedCount, triggerType)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount})
|
||||
}
|
||||
|
||||
func determineTriggerType(webhookData map[string]interface{}) (models.TriggerType, error) {
|
||||
if member, ok := webhookData["member"].(map[string]interface{}); ok {
|
||||
if _, ok := member["current"]; ok {
|
||||
if previous, ok := member["previous"].(map[string]interface{}); ok && len(previous) > 0 {
|
||||
// log member["previous"]
|
||||
utils.InfoLogger.Infof("Previous member data: %v", previous)
|
||||
return models.TriggerMemberUpdated, nil
|
||||
}
|
||||
return models.TriggerMemberCreated, nil
|
||||
}
|
||||
// Assuming member deletion doesn't have 'current' field
|
||||
return models.TriggerMemberDeleted, nil
|
||||
}
|
||||
|
||||
if post, ok := webhookData["post"].(map[string]interface{}); ok {
|
||||
if status, ok := post["status"].(string); ok && status == "published" {
|
||||
return models.TriggerPostPublished, nil
|
||||
}
|
||||
if status, ok := post["status"].(string); ok && status == "scheduled" {
|
||||
return models.TriggerPostScheduled, nil
|
||||
}
|
||||
}
|
||||
|
||||
if page, ok := webhookData["page"].(map[string]interface{}); ok {
|
||||
if status, ok := page["status"].(string); ok && status == "published" {
|
||||
return models.TriggerPagePublished, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data")
|
||||
}
|
||||
70
main.go
Normal file
70
main.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/troneras/ghost-listmonk-connector/handlers"
|
||||
"github.com/troneras/ghost-listmonk-connector/middleware"
|
||||
"github.com/troneras/ghost-listmonk-connector/services"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config, err := utils.LoadConfig()
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
r := gin.Default()
|
||||
|
||||
// CORS configuration
|
||||
corsConfig := cors.DefaultConfig()
|
||||
corsConfig.AllowOrigins = []string{"http://localhost:3000"} // Add your frontend URL here
|
||||
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
|
||||
corsConfig.AllowCredentials = true
|
||||
|
||||
r.Use(cors.New(corsConfig))
|
||||
|
||||
// Initialize services
|
||||
sonStorage, err := services.NewSonStorage("ghost_listmonk.db")
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Fatalf("Failed to initialize son storage: %v", err)
|
||||
}
|
||||
|
||||
listmonkClient := services.NewListmonkClient(config)
|
||||
sonExecutor := services.NewSonExecutor(listmonkClient)
|
||||
sonHandler := handlers.NewSonHandler(sonStorage)
|
||||
webhookHandler := handlers.NewWebhookHandler(sonStorage, sonExecutor)
|
||||
listmonkHandler := handlers.NewListmonkHandler(listmonkClient)
|
||||
|
||||
// Webhook endpoint (no auth required)
|
||||
r.POST("/webhook", webhookHandler.HandleWebhook)
|
||||
|
||||
// Web UI routes (auth required)
|
||||
authorized := r.Group("/")
|
||||
authorized.Use(middleware.APIKeyAuth(config.APIKey))
|
||||
{
|
||||
authorized.GET("/", handleHome)
|
||||
|
||||
// Son routes
|
||||
sons := authorized.Group("/sons")
|
||||
{
|
||||
sons.POST("", sonHandler.Create)
|
||||
sons.GET("", sonHandler.List)
|
||||
sons.GET("/:id", sonHandler.Get)
|
||||
sons.PUT("/:id", sonHandler.Update)
|
||||
sons.DELETE("/:id", sonHandler.Delete)
|
||||
}
|
||||
authorized.GET("/lists", listmonkHandler.GetLists)
|
||||
authorized.GET("/templates", listmonkHandler.GetTemplates)
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Server starting on port %s", config.Port)
|
||||
if err := r.Run(":" + config.Port); err != nil {
|
||||
utils.ErrorLogger.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleHome(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "Welcome to the Ghost-Listmonk Connector"})
|
||||
}
|
||||
24
middleware/auth.go
Normal file
24
middleware/auth.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func APIKeyAuth(validAPIKey string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if apiKey == "" {
|
||||
apiKey = c.Query("api_key")
|
||||
}
|
||||
|
||||
if apiKey != "Bearer "+validAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
68
models/son.go
Normal file
68
models/son.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TriggerType string
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
TriggerMemberCreated TriggerType = "member_created"
|
||||
TriggerMemberDeleted TriggerType = "member_deleted"
|
||||
TriggerMemberUpdated TriggerType = "member_updated"
|
||||
TriggerPagePublished TriggerType = "page_published"
|
||||
TriggerPostPublished TriggerType = "post_published"
|
||||
TriggerPostScheduled TriggerType = "post_scheduled"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionSendTransactionalEmail ActionType = "send_transactional_email"
|
||||
ActionManageSubscriber ActionType = "manage_subscriber"
|
||||
ActionCreateCampaign ActionType = "create_campaign"
|
||||
)
|
||||
|
||||
type Son struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Trigger TriggerType `json:"trigger"`
|
||||
Delay Duration `json:"delay"`
|
||||
Actions []Action `json:"actions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type ActionType `json:"type"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
// Duration is a custom type to handle time.Duration in JSON
|
||||
type Duration time.Duration
|
||||
|
||||
func (d Duration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Duration(d).Minutes())
|
||||
}
|
||||
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
*d = Duration(time.Duration(value) * time.Minute)
|
||||
return nil
|
||||
case string:
|
||||
tmp, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(tmp)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid duration")
|
||||
}
|
||||
}
|
||||
240
previous_conversation.txt
Normal file
240
previous_conversation.txt
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
Are you familiar with Ghost blog platform and listmonk? EditI'm familiar with the Ghost blogging platform, but I don't have detailed knowledge about listmonk. I can share what I know about Ghost and offer to discuss it further if you'd like. Would you like me to elaborate on Ghost, or do you have any specific questions about either platform? CopyRetryAListmonk is an opensource maillist/newsletter manager, it allows you to use Amazon SES for example to send mails. Ghost, on the other side only has integration with Mailchimp, that uses mailjet, what adds a recurring cost. I EditThank you for providing that information about listmonk. It's helpful to understand its functionality as an open-source mailing list and newsletter manager with the ability to use services like Amazon SES for sending emails.
|
||||
You're correct about Ghost's native integration with Mailchimp. However, it's worth noting that Ghost does have some flexibility when it comes to email services:
|
||||
|
||||
Native Mailchimp integration: As you mentioned, this is the built-in option that comes with Ghost, which can indeed add recurring costs.
|
||||
Custom SMTP: Ghost allows you to use a custom SMTP server for sending emails. This means you could potentially set up your own email sending solution or use a different third-party service.
|
||||
API and webhooks: Ghost has a robust API and webhook system that allows for custom integrations. While it might require some development work, it's possible to create custom integrations with other email services or tools.
|
||||
|
||||
Given your interest in both Ghost and listmonk, it sounds like you might be looking for a more cost-effective or flexible email solution for a Ghost-based blog. Is that correct? If so, I can suggest a few potential approaches or discuss how you might integrate these systems. CopyRetryAThis is in part true, Ghost allows you to use a custom SMTP, but only for transactional emails, for newsletter ghost only allows you to use mailgun EditI appreciate you correcting and clarifying that information. You're absolutely right, and I apologize for my mistake. Let me restate the correct information:
|
||||
|
||||
Ghost's newsletter functionality: As you pointed out, Ghost only allows Mailgun for sending newsletters. This is indeed a limitation if you're looking for more flexibility or cost-effective options for newsletter distribution.
|
||||
Transactional emails: Ghost does allow custom SMTP for transactional emails (like password resets, notifications, etc.), but not for newsletters.
|
||||
|
||||
This limitation with Ghost's newsletter functionality can indeed be frustrating for users who want more control over their email infrastructure or are looking to optimize costs.
|
||||
Given this situation, I can see why you're interested in potentially using listmonk alongside Ghost. Some possible approaches could include:
|
||||
|
||||
Using Ghost for your blog content and listmonk for managing your newsletter separately.
|
||||
Exploring if there's a way to integrate listmonk with Ghost using Ghost's API or webhooks, though this would likely require custom development.
|
||||
Investigating if there are any community-developed plugins or integrations that might bridge Ghost and listmonk or other email services.
|
||||
|
||||
Would you like to discuss any of these options further, or do you have a specific goal in mind for combining Ghost and listmonk? CopyRetryAYes, I would like to manage my blog content with Ghost and my newsletter with ListMonk. When a new user registers to the newsletter, I can send a webhook from ghost where I want. I could use this information to then register this user in listmonk. EditThat's a great approach! You're on the right track with using Ghost's webhook functionality to connect it with listmonk. Here's a high-level overview of how you could set this up:
|
||||
|
||||
Ghost Configuration:
|
||||
|
||||
Set up a webhook in Ghost that triggers when a new newsletter subscriber is added.
|
||||
Configure the webhook to send relevant user data (like email address) to an endpoint you control.
|
||||
|
||||
|
||||
Intermediate API/Service:
|
||||
|
||||
Create a simple API or serverless function (using AWS Lambda, Google Cloud Functions, or similar) that receives the webhook from Ghost.
|
||||
This service would process the incoming data from Ghost and then make an API call to listmonk to add the new subscriber.
|
||||
|
||||
|
||||
Listmonk Configuration:
|
||||
|
||||
Set up listmonk and configure it to use your preferred email sending service (like Amazon SES).
|
||||
Ensure listmonk's API is accessible to your intermediate service.
|
||||
|
||||
|
||||
Integration Flow:
|
||||
|
||||
User subscribes on your Ghost blog
|
||||
Ghost sends webhook to your intermediate service
|
||||
Your service processes the data and calls listmonk's API
|
||||
Listmonk adds the new subscriber to your mailing list
|
||||
|
||||
|
||||
|
||||
This setup allows you to maintain Ghost for content management while leveraging listmonk's more flexible and potentially cost-effective email management capabilities.
|
||||
Would you like me to elaborate on any part of this process, such as setting up the webhook in Ghost or creating the intermediate service? CopyRetryAI'm thinking if handling it myself... my use case is that I've a google form collecting tens of thousands o applications and I need to reply to those people in masse (a few thousands per day), can you do some back of the envelope calculation of what the cost would be using lambda? EditCertainly, I can help you with a rough cost estimation for using AWS Lambda to handle this task. Let's break it down step by step:
|
||||
AWS Lambda Cost EstimationClick to open document
|
||||
Based on this calculation, using AWS Lambda for processing your email responses would be very cost-effective. The workload you described (a few thousand emails per day, totaling tens of thousands per month) falls well within AWS Lambda's free tier.
|
||||
Key points:
|
||||
|
||||
Cost: The processing itself would likely be free, assuming you're not using the Lambda free tier for other purposes.
|
||||
Scalability: Lambda can easily handle this workload and scale up if needed.
|
||||
Additional costs: Remember that this calculation doesn't include the cost of actually sending the emails. You'd need to factor in the costs of a service like Amazon SES (Simple Email Service) for sending the emails.
|
||||
SES Costs: As a rough estimate, Amazon SES charges $0.10 per 1,000 emails for the first 62,000 emails each month, and $0.10 per 10,000 emails thereafter. So for 300,000 emails per month, the SES cost would be approximately $9.80.
|
||||
|
||||
Would you like me to elaborate on any part of this calculation or discuss other aspects of implementing this solution? CopyRetryAThe other option would be to deploy a connector service, I could probably write this in go and host it alongside my listmonk and ghost EditThat's an excellent alternative approach. Developing a custom connector service in Go and hosting it alongside your Ghost and listmonk instances has several advantages:
|
||||
|
||||
Cost-effective: You'd avoid additional cloud service costs for functions like AWS Lambda.
|
||||
Control: Full control over the integration logic and deployment.
|
||||
Performance: Go is known for its efficiency and low resource usage, making it ideal for this kind of service.
|
||||
Simplicity: Keeping everything on the same infrastructure can simplify management and troubleshooting.
|
||||
|
||||
Here's a high-level outline of how you might structure this:
|
||||
|
||||
Go Connector Service:
|
||||
|
||||
Create a small HTTP server in Go that listens for webhooks from Ghost.
|
||||
Process the incoming data and use listmonk's API to add new subscribers.
|
||||
Optionally, add logging and error handling for monitoring.
|
||||
|
||||
|
||||
Deployment:
|
||||
|
||||
Deploy this Go service on the same server or container environment where you're running Ghost and listmonk.
|
||||
Ensure proper network configuration so Ghost can reach your connector, and your connector can reach listmonk.
|
||||
|
||||
|
||||
Configuration:
|
||||
|
||||
Set up Ghost to send webhooks to your Go connector service when new subscribers sign up.
|
||||
Configure the connector with the necessary API details for listmonk.
|
||||
|
||||
|
||||
|
||||
This approach would be particularly beneficial if you're already comfortable with Go and have the infrastructure in place to host additional services. It provides a lightweight, efficient solution that's fully under your control.
|
||||
Would you like me to sketch out a basic structure for this Go connector service? Or do you have any specific questions about implementing this approach? CopyRetrylistmonk_apiyamlAExample webhook sent when I registered a user:Copy{
|
||||
"member": {
|
||||
"current": {
|
||||
"id": "66b68e4d46c2c0000128b809",
|
||||
"uuid": "c3570109-5572-4ae5-91be-f4bfe2f02653",
|
||||
"email": "connect@radianceflow.com",
|
||||
"name": null,
|
||||
"note": null,
|
||||
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.122.19\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
|
||||
"subscribed": true,
|
||||
"created_at": "2024-08-09T21:46:53.000Z",
|
||||
"updated_at": "2024-08-09T21:46:53.000Z",
|
||||
"labels": [],
|
||||
"subscriptions": [],
|
||||
"avatar_image": "https://www.gravatar.com/avatar/e7e64e923ef0d0a0967445b2b73c7144?s=250&r=g&d=blank",
|
||||
"comped": false,
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null,
|
||||
"status": "free",
|
||||
"last_seen_at": null,
|
||||
"tiers": [],
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "troneras",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
},
|
||||
"previous": {}
|
||||
}
|
||||
}We will need a service that can be deployed with docker compose, it should handle the webhooks from ghost, so it should have a custom server listening for them for example on service:8808/webhookIt should also have the domain where listmonk is in order to call it's API. Management will be done from a web interface. So we need to choose carefully the go framework. For authentication on the webui we can use basic auth or similar and configure this via env variables. The functionality of the UI will be as follows:Similar to Zapier, the user will be able to create multiple Zaps, lets call it something else to avoid branding conflicts, we could call it Sons, from Sonnet ;) In the Zap, sorry, I mean in the Son, he will do the following:
|
||||
1. Give it a name
|
||||
2. Select the trigger, (Event)
|
||||
It will be one of the following:
|
||||
* Member Created (Triggers when a new member is added)
|
||||
* Member Deleted (Triggerst when a member is deleted)
|
||||
* Member Updated (Triggers when a member is updated)
|
||||
* Page Published (Triggers when a page is published)
|
||||
* Post Published (Triggers when a post is published)
|
||||
* Post Scheduled (Triggers when a post is scheduled)
|
||||
1. Select if it should be inmediate or with a delay. Adding a delay allows users to create email sequences, that can be sent out at different intervals: a welcome email right away, a survey request five days later, and an exclusive offer one month after that.
|
||||
2. Select the action. (can be multiple)
|
||||
1. sending a transactional email, using a template from listmonk (we will need to retrieve the templates from the api so the user can see the name from a dropdown)
|
||||
2. Creating/deleting a subscriber. Adding subscriber to a list
|
||||
3. Creating a campaign (will dig into this later, but probably we will need to select a template, a list to send it to, a date to send it, and we can probably pass some data to replace in the listmonk template placeholders)
|
||||
3. Can you create the above application step by step, it doesn't need to be in one go.Certainly! I'll guide you through creating this application step by step. Let's start with the basic structure and gradually build up the functionality.
|
||||
Step 1: Project Setup
|
||||
Let's begin by setting up the project structure and choosing a Go web framework. For this application, we'll use the Gin framework, which is lightweight, fast, and has good support for API development.
|
||||
Project SetupClick to open code
|
||||
Now, let's create a basic main.go file to set up our Gin server:
|
||||
main.goClick to open code
|
||||
This sets up a basic Gin server with a webhook endpoint. We'll expand on this as we add more functionality.
|
||||
Step 2: Docker Setup
|
||||
Let's create a Dockerfile and a docker-compose.yml file for our application:
|
||||
DockerfileClick to open code
|
||||
Now, let's create a docker-compose.yml file:
|
||||
docker-compose.ymlClick to open code
|
||||
This docker-compose file sets up our application, Listmonk, and a PostgreSQL database for Listmonk.
|
||||
Step 3: Basic Authentication
|
||||
Let's implement basic authentication for our web UI. We'll create a middleware for this:
|
||||
middleware/auth.goClick to open code
|
||||
Now, let's update our main.go to use this middleware:
|
||||
main.goClick to open code
|
||||
This sets up a basic structure for our application with authentication for the web UI. In the next steps, we'll implement the core functionality for handling webhooks, managing "Sons" (our Zap-like automations), and interacting with the Listmonk API.
|
||||
Let me know if you want to proceed with the next steps or if you need any clarification on what we've done so far. CopyRetryAready EditGreat! Let's move on to implementing the core functionality of our application.
|
||||
Step 4: Implement Son (Automation) Model
|
||||
First, we'll create a model for our Son (automation) structure. This will define how we store and manage our automations.
|
||||
models/son.goClick to open code
|
||||
Step 5: Implement Son Storage
|
||||
Now, let's create a simple storage mechanism for our Sons. For simplicity, we'll use in-memory storage, but in a production environment, you'd want to use a database.
|
||||
services/son_storage.goClick to open code
|
||||
Step 6: Implement Son Handlers
|
||||
Now, let's create handlers for CRUD operations on Sons.
|
||||
handlers/son_handlers.goClick to open code
|
||||
Step 7: Update main.go to include Son routes
|
||||
Let's update our main.go file to include the new Son routes:
|
||||
main.goClick to open code
|
||||
These steps set up the basic structure for managing our Sons (automations). In the next steps, we'll implement the webhook handling logic and the Listmonk API integration to actually perform the actions defined in the Sons.
|
||||
Would you like to proceed with implementing the webhook handling and Listmonk integration, or do you have any questions about what we've done so far? CopyRetryAcontinue EditCertainly! Let's move on to implementing the webhook handling and Listmonk integration.
|
||||
Step 8: Implement Webhook Handling
|
||||
First, we'll create a handler for processing the webhooks from Ghost. We'll need to parse the incoming webhook data and trigger the appropriate Sons based on the event type.
|
||||
handlers/webhook_handler.goClick to open code
|
||||
Step 9: Implement Listmonk API Client
|
||||
Now, let's create a simple Listmonk API client to interact with Listmonk:
|
||||
services/listmonk_client.goClick to open code
|
||||
Step 10: Implement Son Executor
|
||||
Now, let's create a service to execute the actions defined in a Son:
|
||||
services/son_executor.goClick to open code
|
||||
Step 11: Update main.go to include the new components
|
||||
Let's update our main.go file to include the new webhook handler and Listmonk client:
|
||||
main.goClick to open code
|
||||
With these changes, we've implemented the core functionality of our Ghost-Listmonk connector. The application can now:
|
||||
|
||||
Receive webhooks from Ghost
|
||||
Process the webhooks and determine the trigger type
|
||||
Find relevant Sons based on the trigger type
|
||||
Execute the actions defined in the Sons, including sending transactional emails, managing subscribers, and creating campaigns in Listmonk
|
||||
|
||||
The next steps would be to:
|
||||
|
||||
Implement error handling and logging throughout the application
|
||||
Add more comprehensive tests
|
||||
Implement a user interface for managing Sons
|
||||
Add more sophisticated scheduling and retry mechanisms for action execution
|
||||
Implement caching and performance optimizations
|
||||
|
||||
Would you like to focus on any of these areas next, or do you have any questions about the implementation so far? CopyRetryALet's continue with the next step EditCertainly! Let's focus on implementing error handling and logging throughout the application. This will make our application more robust and easier to debug.
|
||||
Step 12: Implement Logging
|
||||
First, let's create a custom logger that we can use throughout our application:
|
||||
utils/logger.goClick to open code
|
||||
Now, let's update our services and handlers to use this logger and implement better error handling.
|
||||
Step 13: Update Son Storage with Logging and Error Handling
|
||||
services/son_storage.goClick to open code
|
||||
Step 14: Update Listmonk Client with Logging and Error Handling
|
||||
services/listmonk_client.goClick to open code
|
||||
Step 15: Update Son Executor with Logging and Error Handling
|
||||
services/son_executor.goClick to open code
|
||||
Step 16: Update Webhook Handler with Logging and Error Handling
|
||||
handlers/webhook_handler.goClick to open code CopyRetryClaude’s response was limited as it hit the maximum length allowed at this time.Acontinue with step 16 EditCertainly! Let's continue with step 16, updating the Webhook Handler with logging and error handling.
|
||||
handlers/webhook_handler.goClick to open code
|
||||
Now, let's add a utility function to pretty print JSON for logging purposes:
|
||||
utils/pretty_print.goClick to open code
|
||||
Step 17: Update Son Handler with Logging and Error Handling
|
||||
handlers/son_handlers.goClick to open code
|
||||
Step 18: Update main.go to use the new logger
|
||||
Finally, let's update our main.go file to use the new logger:
|
||||
main.goClick to open code
|
||||
With these changes, we've significantly improved our application's error handling and logging. This will make it easier to debug issues and monitor the application's behavior in production.
|
||||
The next steps could include:
|
||||
|
||||
Implementing comprehensive tests for all components
|
||||
Creating a user interface for managing Sons
|
||||
Adding more sophisticated scheduling and retry mechanisms for action execution
|
||||
Implementing caching and performance optimizations
|
||||
|
||||
Would you like to focus on any of these areas next, or do you have any other specific improvements in mind? CopyRetryAThere's an error on webhook_handler in this linereturn "", utils.ErrorLogger.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data") EditI apologize for the error. You're right, and thank you for catching that. The ErrorLogger doesn't have a NewError method. Let's correct this by creating a custom error type and using it in the webhook_handler.go file.
|
||||
First, let's create a new file for custom errors:
|
||||
utils/errors.goClick to open code
|
||||
Now, let's update the webhook_handler.go file to use this new custom error:
|
||||
handlers/webhook_handler.goClick to open code
|
||||
This correction ensures that we're creating and returning a proper custom error when we can't determine the trigger type. The utils.NewError function creates a new CustomError with the given code and message.
|
||||
When using this error in other parts of the code, you can type assert it to access the Code and Message fields if needed:
|
||||
goCopyif err, ok := err.(*utils.CustomError); ok {
|
||||
utils.ErrorLogger.Errorf("Custom error occurred: %s - %s", err.Code, err.Message)
|
||||
} else {
|
||||
utils.ErrorLogger.Errorf("An error occurred: %v", err)
|
||||
}
|
||||
This approach provides more structured error handling and allows you to include additional context (the error code) when logging or responding to errors.
|
||||
1634
repopack-output-backend-go.txt
Normal file
1634
repopack-output-backend-go.txt
Normal file
File diff suppressed because it is too large
Load diff
235
services/database.go
Normal file
235
services/database.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewDatabase(dbPath string) (*Database, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database := &Database{db: db}
|
||||
if err := database.createTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (d *Database) createTables() error {
|
||||
_, err := d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS sons (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
trigger TEXT,
|
||||
delay INTEGER,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
son_id TEXT,
|
||||
type TEXT,
|
||||
parameters TEXT,
|
||||
FOREIGN KEY (son_id) REFERENCES sons(id)
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) CreateSon(son *models.Son) error {
|
||||
son.ID = utils.GenerateUUID()
|
||||
son.CreatedAt = time.Now()
|
||||
son.UpdatedAt = time.Now()
|
||||
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO sons (id, name, trigger, delay, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
son.ID, son.Name, son.Trigger, int64(son.Delay), son.CreatedAt, son.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, action := range son.Actions {
|
||||
parametersJSON, err := json.Marshal(action.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
|
||||
son.ID, action.Type, string(parametersJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *Database) GetSon(id string) (*models.Son, error) {
|
||||
son := &models.Son{}
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, name, trigger, delay, created_at, updated_at FROM sons WHERE id = ?",
|
||||
id,
|
||||
).Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrSonNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var action models.Action
|
||||
var parametersJSON string
|
||||
err := rows.Scan(&action.Type, ¶metersJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
son.Actions = append(son.Actions, action)
|
||||
}
|
||||
|
||||
return son, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateSon(son *models.Son) error {
|
||||
son.UpdatedAt = time.Now()
|
||||
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE sons SET name = ?, trigger = ?, delay = ?, updated_at = ? WHERE id = ?",
|
||||
son.Name, son.Trigger, int64(son.Delay), son.UpdatedAt, son.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", son.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, action := range son.Actions {
|
||||
parametersJSON, err := json.Marshal(action.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
|
||||
son.ID, action.Type, string(parametersJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *Database) DeleteSon(id string) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := tx.Exec("DELETE FROM sons WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrSonNotFound
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *Database) ListSons() ([]models.Son, error) {
|
||||
rows, err := d.db.Query("SELECT id, name, trigger, delay, created_at, updated_at FROM sons")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sons []models.Son
|
||||
for rows.Next() {
|
||||
var son models.Son
|
||||
err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sons = append(sons, son)
|
||||
}
|
||||
|
||||
for i, son := range sons {
|
||||
actionRows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", son.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer actionRows.Close()
|
||||
|
||||
for actionRows.Next() {
|
||||
var action models.Action
|
||||
var parametersJSON string
|
||||
err := actionRows.Scan(&action.Type, ¶metersJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sons[i].Actions = append(sons[i].Actions, action)
|
||||
}
|
||||
}
|
||||
|
||||
return sons, nil
|
||||
}
|
||||
174
services/listmonk_client.go
Normal file
174
services/listmonk_client.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
type ListmonkList struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListmonkTemplate struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListmonkClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewListmonkClient(config *utils.Config) *ListmonkClient {
|
||||
return &ListmonkClient{
|
||||
baseURL: config.ListmonkURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ListmonkClient) GetLists() ([]ListmonkList, error) {
|
||||
resp, err := c.client.Get(c.baseURL + "/api/lists?page=1&per_page=100")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching lists: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Results []ListmonkList `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("error decoding response: %w", err)
|
||||
}
|
||||
|
||||
return result.Data.Results, nil
|
||||
}
|
||||
|
||||
func (c *ListmonkClient) GetTemplates() ([]ListmonkTemplate, error) {
|
||||
resp, err := c.client.Get(c.baseURL + "/api/templates?page=1&per_page=100")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching templates: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []ListmonkTemplate `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("error decoding response: %w", err)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}) error {
|
||||
payload := map[string]interface{}{
|
||||
"subscriber_email": subscriberEmail,
|
||||
"template_id": templateID,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Post(c.baseURL+"/api/tx", "application/json", bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to send transactional email: %v", err)
|
||||
return fmt.Errorf("failed to send transactional email: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Sent transactional email to %s using template %d", subscriberEmail, templateID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int) error {
|
||||
payload := map[string]interface{}{
|
||||
"email": email,
|
||||
"name": name,
|
||||
"status": status,
|
||||
"lists": lists,
|
||||
"preconfirm_subscriptions": true,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// log the payload
|
||||
utils.InfoLogger.Infof("Payload: %s", string(jsonPayload))
|
||||
|
||||
resp, err := c.client.Post(c.baseURL+"/api/subscribers", "application/json", bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to manage subscriber: %v", err)
|
||||
return fmt.Errorf("failed to manage subscriber: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Managed subscriber %s with status %s", email, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string) error {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"subject": subject,
|
||||
"lists": lists,
|
||||
"template_id": templateID,
|
||||
"send_at": sendAt,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Post(c.baseURL+"/api/campaigns", "application/json", bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to create campaign: %v", err)
|
||||
return fmt.Errorf("failed to create campaign: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Created campaign %s with subject %s", name, subject)
|
||||
return nil
|
||||
}
|
||||
47
services/listmonk_client_test.go
Normal file
47
services/listmonk_client_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListmonkClient(t *testing.T) {
|
||||
// Create a mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tx":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data": true}`))
|
||||
case "/api/subscribers":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data": {"id": 1}}`))
|
||||
case "/api/campaigns":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data": {"id": 1}}`))
|
||||
default:
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create a client that uses the mock server URL
|
||||
client := &ListmonkClient{
|
||||
baseURL: server.URL,
|
||||
client: server.Client(),
|
||||
}
|
||||
|
||||
// Test SendTransactionalEmail
|
||||
err := client.SendTransactionalEmail(1, "test@example.com", map[string]interface{}{"name": "Test"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test ManageSubscriber
|
||||
err = client.ManageSubscriber("test@example.com", "Test User", "enabled", []int{1})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test CreateCampaign
|
||||
err = client.CreateCampaign("Test Campaign", "Test Subject", []int{1}, 1, "2023-01-01T00:00:00Z")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
100
services/son_executor.go
Normal file
100
services/son_executor.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
type SonExecutor struct {
|
||||
listmonkClient *ListmonkClient
|
||||
}
|
||||
|
||||
func NewSonExecutor(listmonkClient *ListmonkClient) *SonExecutor {
|
||||
return &SonExecutor{
|
||||
listmonkClient: listmonkClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}) {
|
||||
utils.InfoLogger.Infof("Executing Son: %s", son.ID)
|
||||
|
||||
if son.Delay > 0 {
|
||||
utils.InfoLogger.Infof("Delaying execution of Son %s for %v", son.ID, time.Duration(son.Delay))
|
||||
time.Sleep(time.Duration(son.Delay))
|
||||
}
|
||||
|
||||
for _, action := range son.Actions {
|
||||
err := e.executeAction(action, data)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Error executing action for Son %s: %v", son.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Finished executing Son: %s", son.ID)
|
||||
}
|
||||
|
||||
func (e *SonExecutor) executeAction(action models.Action, data map[string]interface{}) error {
|
||||
utils.InfoLogger.Infof("Executing action: %s", action.Type)
|
||||
|
||||
switch action.Type {
|
||||
case models.ActionSendTransactionalEmail:
|
||||
return e.sendTransactionalEmail(action.Parameters, data)
|
||||
case models.ActionManageSubscriber:
|
||||
return e.manageSubscriber(action.Parameters, data)
|
||||
case models.ActionCreateCampaign:
|
||||
return e.createCampaign(action.Parameters, data)
|
||||
default:
|
||||
err := fmt.Errorf("unknown action type: %s", action.Type)
|
||||
utils.ErrorLogger.Errorf("%v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SonExecutor) sendTransactionalEmail(params map[string]interface{}, data map[string]interface{}) error {
|
||||
templateID := int(params["template_id"].(float64))
|
||||
subscriberEmail := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)
|
||||
|
||||
utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, templateID)
|
||||
return e.listmonkClient.SendTransactionalEmail(templateID, subscriberEmail, data)
|
||||
}
|
||||
|
||||
func (e *SonExecutor) manageSubscriber(params map[string]interface{}, data map[string]interface{}) error {
|
||||
email := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)
|
||||
|
||||
utils.InfoLogger.Infof("Managing subscriber %s", email)
|
||||
utils.InfoLogger.Infof("Params: %v", params)
|
||||
var name string
|
||||
|
||||
if member, ok := data["member"].(map[string]interface{}); ok {
|
||||
if current, ok := member["current"].(map[string]interface{}); ok {
|
||||
if n, ok := current["name"].(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "enabled" //params["status"].(string)
|
||||
lists := []int{}
|
||||
for _, listID := range params["lists"].([]interface{}) {
|
||||
lists = append(lists, int(listID.(float64)))
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Managing subscriber %s with status %s", email, status)
|
||||
return e.listmonkClient.ManageSubscriber(email, name, status, lists)
|
||||
}
|
||||
|
||||
func (e *SonExecutor) createCampaign(params map[string]interface{}, data map[string]interface{}) error {
|
||||
name := params["name"].(string)
|
||||
subject := params["subject"].(string)
|
||||
lists := []int{}
|
||||
for _, listID := range params["lists"].([]interface{}) {
|
||||
lists = append(lists, int(listID.(float64)))
|
||||
}
|
||||
templateID := int(params["template_id"].(float64))
|
||||
sendAt := params["send_at"].(string)
|
||||
|
||||
utils.InfoLogger.Infof("Creating campaign %s with subject %s", name, subject)
|
||||
return e.listmonkClient.CreateCampaign(name, subject, lists, templateID, sendAt)
|
||||
}
|
||||
199
services/son_storage.go
Normal file
199
services/son_storage.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
"github.com/troneras/ghost-listmonk-connector/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSonAlreadyExists = errors.New("son with this ID already exists")
|
||||
ErrSonNotFound = errors.New("son not found")
|
||||
)
|
||||
|
||||
type SonStorage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSonStorage(dbPath string) (*SonStorage, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storage := &SonStorage{db: db}
|
||||
if err := storage.createTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func (s *SonStorage) createTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS sons (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
trigger TEXT,
|
||||
delay INTEGER,
|
||||
actions TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SonStorage) Create(son *models.Son) error {
|
||||
son.ID = utils.GenerateUUID()
|
||||
son.CreatedAt = time.Now()
|
||||
son.UpdatedAt = time.Now()
|
||||
|
||||
actionsJSON, err := json.Marshal(son.Actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO sons (id, name, trigger, delay, actions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
son.ID, son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.CreatedAt, son.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Created new Son with ID: %s", son.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SonStorage) Get(id string) (models.Son, error) {
|
||||
var son models.Son
|
||||
var actionsJSON []byte
|
||||
var delayInt int64
|
||||
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons WHERE id = ?",
|
||||
id,
|
||||
).Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
utils.ErrorLogger.Errorf("Failed to get Son: %v", ErrSonNotFound)
|
||||
return models.Son{}, ErrSonNotFound
|
||||
}
|
||||
utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
|
||||
return models.Son{}, err
|
||||
}
|
||||
|
||||
son.Delay = models.Duration(time.Duration(delayInt))
|
||||
|
||||
err = json.Unmarshal(actionsJSON, &son.Actions)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
|
||||
return models.Son{}, err
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Retrieved Son with ID: %s", id)
|
||||
return son, nil
|
||||
}
|
||||
|
||||
func (s *SonStorage) Update(son models.Son) error {
|
||||
son.UpdatedAt = time.Now()
|
||||
|
||||
actionsJSON, err := json.Marshal(son.Actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
"UPDATE sons SET name = ?, trigger = ?, delay = ?, actions = ?, updated_at = ? WHERE id = ?",
|
||||
son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.UpdatedAt, son.ID,
|
||||
)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
utils.ErrorLogger.Errorf("Failed to update Son: %v", ErrSonNotFound)
|
||||
return ErrSonNotFound
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Updated Son with ID: %s", son.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SonStorage) Delete(id string) error {
|
||||
result, err := s.db.Exec("DELETE FROM sons WHERE id = ?", id)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
utils.ErrorLogger.Errorf("Failed to delete Son: %v", ErrSonNotFound)
|
||||
return ErrSonNotFound
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Deleted Son with ID: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SonStorage) List() ([]models.Son, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons")
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sons []models.Son
|
||||
for rows.Next() {
|
||||
var son models.Son
|
||||
var actionsJSON []byte
|
||||
var delayInt int64
|
||||
|
||||
err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to scan Son: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
son.Delay = models.Duration(time.Duration(delayInt))
|
||||
|
||||
err = json.Unmarshal(actionsJSON, &son.Actions)
|
||||
if err != nil {
|
||||
utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sons = append(sons, son)
|
||||
}
|
||||
|
||||
if sons == nil {
|
||||
sons = []models.Son{} // Ensure we always return an array, even if empty
|
||||
}
|
||||
|
||||
utils.InfoLogger.Infof("Retrieved list of %d Sons", len(sons))
|
||||
return sons, nil
|
||||
}
|
||||
82
services/son_storage_test.go
Normal file
82
services/son_storage_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/troneras/ghost-listmonk-connector/models"
|
||||
)
|
||||
|
||||
func TestSonStorage(t *testing.T) {
|
||||
// Create a temporary database for testing
|
||||
tempDBPath := "test_ghost_listmonk.db"
|
||||
storage, err := NewSonStorage(tempDBPath)
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tempDBPath) // Clean up the temporary database file after tests
|
||||
|
||||
// Test Create
|
||||
son := models.Son{
|
||||
Name: "Test Son",
|
||||
Trigger: models.TriggerMemberCreated,
|
||||
Delay: models.Duration(5 * time.Minute),
|
||||
Actions: []models.Action{
|
||||
{
|
||||
Type: models.ActionSendTransactionalEmail,
|
||||
Parameters: map[string]interface{}{
|
||||
"template_id": float64(1), // Use float64 to match JSON unmarshaling behavior
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = storage.Create(&son)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, son.ID) // Ensure an ID was generated
|
||||
|
||||
// Test Get
|
||||
retrievedSon, err := storage.Get(son.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, son.Name, retrievedSon.Name)
|
||||
assert.Equal(t, son.Trigger, retrievedSon.Trigger)
|
||||
assert.Equal(t, son.Delay, retrievedSon.Delay)
|
||||
assert.Len(t, retrievedSon.Actions, 1)
|
||||
assert.Equal(t, son.Actions[0].Type, retrievedSon.Actions[0].Type)
|
||||
assert.Equal(t, son.Actions[0].Parameters["template_id"], retrievedSon.Actions[0].Parameters["template_id"])
|
||||
|
||||
// Test Update
|
||||
son.Name = "Updated Test Son"
|
||||
err = storage.Update(son)
|
||||
assert.NoError(t, err)
|
||||
|
||||
updatedSon, err := storage.Get(son.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Updated Test Son", updatedSon.Name)
|
||||
|
||||
// Test List
|
||||
sons, err := storage.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, sons, 1)
|
||||
assert.Equal(t, son.ID, sons[0].ID)
|
||||
|
||||
// Test Delete
|
||||
err = storage.Delete(son.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = storage.Get(son.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrSonNotFound, err)
|
||||
|
||||
// Test error cases
|
||||
err = storage.Create(&son)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Attempt to update a non-existent son (should fail)
|
||||
nonExistentSon := models.Son{ID: "non-existent", Name: "Non-existent Son"}
|
||||
err = storage.Update(nonExistentSon)
|
||||
assert.Equal(t, ErrSonNotFound, err)
|
||||
|
||||
err = storage.Delete("non-existent")
|
||||
assert.Equal(t, ErrSonNotFound, err)
|
||||
}
|
||||
3
ui/.eslintrc.json
Normal file
3
ui/.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
36
ui/.gitignore
vendored
Normal file
36
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
ui/README.md
Normal file
36
ui/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
BIN
ui/app/favicon.ico
Normal file
BIN
ui/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
31
ui/app/layout.tsx
Normal file
31
ui/app/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import "@/styles/globals.css";
|
||||
import { Inter as FontSans } from "next/font/google";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Layout from "@/components/layout";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
fontSans.variable
|
||||
)}
|
||||
>
|
||||
<Layout>{children}</Layout>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
ui/app/page.tsx
Normal file
5
ui/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import Dashboard from "@/components/Dashboard";
|
||||
|
||||
export default function HomePage() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
209
ui/app/sons/[id]/page.tsx
Normal file
209
ui/app/sons/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useSon } from "@/hooks/useSon";
|
||||
import { useSons } from "@/hooks/useSons";
|
||||
import { useLists } from "@/hooks/useLists";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
import { useCustomToast } from "@/hooks/useCustomToast";
|
||||
import { SonDetailsForm } from "@/components/SonDetailsForm";
|
||||
import { ActionForm } from "@/components/ActionForm";
|
||||
import { editableSonSchema } from "@/lib/schemas";
|
||||
import { EditableSon, Son } from "@/lib/types";
|
||||
|
||||
// Define a type guard function
|
||||
function isValidTrigger(trigger: string): trigger is EditableSon["trigger"] {
|
||||
return [
|
||||
"member_created",
|
||||
"member_deleted",
|
||||
"member_updated",
|
||||
"post_published",
|
||||
"post_scheduled",
|
||||
].includes(trigger);
|
||||
}
|
||||
|
||||
export default function SonDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const {
|
||||
son,
|
||||
loading: sonLoading,
|
||||
error: sonError,
|
||||
} = useSon(params.id as string);
|
||||
const { updateSon, deleteSon } = useSons();
|
||||
const { showToast } = useCustomToast();
|
||||
const { lists, loading: listsLoading, error: listsError } = useLists();
|
||||
const {
|
||||
templates,
|
||||
loading: templatesLoading,
|
||||
error: templatesError,
|
||||
} = useTemplates();
|
||||
|
||||
const form = useForm<EditableSon>({
|
||||
resolver: zodResolver(editableSonSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
trigger: "member_created",
|
||||
delay: 0,
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "actions",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (son) {
|
||||
const { name, trigger, delay, actions } = son;
|
||||
|
||||
if (isValidTrigger(trigger)) {
|
||||
form.reset({
|
||||
name,
|
||||
trigger,
|
||||
delay,
|
||||
actions: actions.map((action) => ({
|
||||
type: action.type,
|
||||
parameters: action.parameters,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
console.error(`Invalid trigger value: ${trigger}`);
|
||||
form.reset({
|
||||
name,
|
||||
trigger: "member_created",
|
||||
delay,
|
||||
actions: actions.map((action) => ({
|
||||
type: action.type,
|
||||
parameters: action.parameters,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [son, form]);
|
||||
|
||||
if (sonLoading || listsLoading || templatesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">Loading...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sonError || listsError || templatesError) {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Error:{" "}
|
||||
{sonError?.message || listsError?.message || templatesError?.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!son) return <div>Son not found</div>;
|
||||
|
||||
const onSubmit = async (data: EditableSon) => {
|
||||
try {
|
||||
await updateSon(son.id, data);
|
||||
showToast("Success", "Son updated successfully");
|
||||
router.push("/sons");
|
||||
} catch (error) {
|
||||
showToast("Error", "Failed to update Son", "destructive");
|
||||
console.error("Failed to update Son:", error);
|
||||
}
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteSon(son.id);
|
||||
showToast("Success", "Son deleted successfully");
|
||||
router.push("/sons");
|
||||
} catch (error) {
|
||||
showToast("Error", "Failed to delete Son", "destructive");
|
||||
console.error("Failed to delete Son:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Edit Son: {son.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Created at: {new Date(son.created_at).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Last updated: {new Date(son.updated_at).toLocaleString()}
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<SonDetailsForm form={form} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{fields.map((field, index) => (
|
||||
<ActionForm
|
||||
key={field.id}
|
||||
form={form}
|
||||
index={index}
|
||||
remove={remove}
|
||||
lists={lists}
|
||||
templates={templates}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
append({ type: "send_transactional_email", parameters: {} })
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" /> Add Action
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Save Changes
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Son</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
Son.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
ui/app/sons/layout.tsx
Normal file
3
ui/app/sons/layout.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="max-w-3xl mx-auto">{children}</div>;
|
||||
}
|
||||
10
ui/app/sons/new/page.tsx
Normal file
10
ui/app/sons/new/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import SonCreationForm from "@/components/SonCreationForm";
|
||||
|
||||
export default function NewSonPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Create New Son</h1>
|
||||
<SonCreationForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/app/sons/page.tsx
Normal file
21
ui/app/sons/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { SonList } from "@/components/SonList";
|
||||
|
||||
export default function SonListPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Manage Sons</h1>
|
||||
<Link href="/sons/new">
|
||||
<Button>Create New Son</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SonList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
ui/components.json
Normal file
17
ui/components.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
148
ui/components/ActionForm.tsx
Normal file
148
ui/components/ActionForm.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import React from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { CampaignActionFields } from "./CampaignActionFields";
|
||||
import { ManageSubscriberActionFields } from "./ManageSubscriberActionFields";
|
||||
import { ActionFormProps } from "@/lib/types";
|
||||
|
||||
export function ActionForm({
|
||||
form,
|
||||
index,
|
||||
remove,
|
||||
lists,
|
||||
templates,
|
||||
}: ActionFormProps) {
|
||||
const actionType = form.watch(`actions.${index}.type`);
|
||||
const trigger = form.watch("trigger");
|
||||
|
||||
return (
|
||||
<Card className="border border-gray-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Action {index + 1}
|
||||
</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.type`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(`actions.${index}.parameters`, {});
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an action type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="send_transactional_email">
|
||||
Send Transactional Email
|
||||
</SelectItem>
|
||||
<SelectItem value="manage_subscriber">
|
||||
Manage Subscriber
|
||||
</SelectItem>
|
||||
<SelectItem value="create_campaign">
|
||||
Create Campaign
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
{actionType === "manage_subscriber" && (
|
||||
<FormDescription>
|
||||
Subscriber management automatically adds or removes
|
||||
subscribers on listmonk and adds to lists based on the trigger
|
||||
event and newsletters from ghost blog.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{actionType === "create_campaign" && (
|
||||
<CampaignActionFields
|
||||
form={form}
|
||||
index={index}
|
||||
lists={lists}
|
||||
templates={templates}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionType === "manage_subscriber" && trigger == "member_created" && (
|
||||
<ManageSubscriberActionFields
|
||||
form={form}
|
||||
index={index}
|
||||
lists={lists}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionType === "send_transactional_email" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Parameters</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsedValue = JSON.parse(e.target.value);
|
||||
field.onChange(parsedValue);
|
||||
} catch (error) {
|
||||
field.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
typeof field.value === "object"
|
||||
? JSON.stringify(field.value, null, 2)
|
||||
: field.value
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the parameters for the transactional email as JSON.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
166
ui/components/CampaignActionFields.tsx
Normal file
166
ui/components/CampaignActionFields.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { CampaignActionFieldsProps } from "@/lib/types";
|
||||
|
||||
export function CampaignActionFields({ form, index, lists, templates }: CampaignActionFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.subject`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter campaign subject" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The subject line for your campaign email.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.lists`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const newLists = [...(field.value || []), parseInt(value)];
|
||||
field.onChange(newLists);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((list) => (
|
||||
<SelectItem key={list.id} value={list.id.toString()}>
|
||||
{list.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{field.value?.map((listId: number) => {
|
||||
const list = lists.find((l) => l.id === listId);
|
||||
return (
|
||||
<Badge key={listId} variant="secondary" className="px-2 py-1">
|
||||
{list ? list.name : `List ${listId}`}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-1 h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
const newLists = field.value.filter(
|
||||
(id: number) => id !== listId
|
||||
);
|
||||
field.onChange(newLists);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select the lists to send this campaign to.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.template_id`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((template) => (
|
||||
<SelectItem
|
||||
key={template.id}
|
||||
value={template.id.toString()}
|
||||
>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose the template for your campaign email.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.tags`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter comma-separated tags" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Add tags to categorize your campaign (comma-separated).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.send_now`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Send Now</FormLabel>
|
||||
<FormDescription>
|
||||
Toggle to send the campaign immediately upon creation.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
ui/components/Dashboard.tsx
Normal file
113
ui/components/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { ChartContainer, ChartConfig } from "@/components/ui/chart";
|
||||
|
||||
// Mock data - replace with actual API calls in production
|
||||
const recentActivity = [
|
||||
{
|
||||
id: 1,
|
||||
action: 'Son "Welcome Email" triggered',
|
||||
timestamp: "2024-08-10T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
action: 'New Son "Survey Request" created',
|
||||
timestamp: "2024-08-10T09:15:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
action: 'Son "Monthly Newsletter" modified',
|
||||
timestamp: "2024-08-09T16:45:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const sonStats = [
|
||||
{ name: "Welcome Email", executions: 120, success: 115, failure: 5 },
|
||||
{ name: "Survey Request", executions: 80, success: 78, failure: 2 },
|
||||
{ name: "Monthly Newsletter", executions: 50, success: 50, failure: 0 },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
executions: {
|
||||
label: "Executions",
|
||||
color: "#2563eb",
|
||||
},
|
||||
success: {
|
||||
label: "Success",
|
||||
color: "#60a5fa",
|
||||
},
|
||||
failure: {
|
||||
label: "Failure",
|
||||
color: "#d1d5db",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export default function Dashboard() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
setTimeout(() => setIsLoading(false), 1000);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentActivity.map((activity) => (
|
||||
<Alert key={activity.id} className="mb-2">
|
||||
<AlertTitle>{activity.action}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{new Date(activity.timestamp).toLocaleString()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Son Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
>
|
||||
<BarChart data={sonStats}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="success" fill="var(--color-success)" />
|
||||
<Bar dataKey="failure" fill="var(--color-failure)" />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
ui/components/ManageSubscriberActionFields.tsx
Normal file
88
ui/components/ManageSubscriberActionFields.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { ManageSubscriberActionFieldsProps } from "@/lib/types";
|
||||
|
||||
export function ManageSubscriberActionFields({
|
||||
form,
|
||||
index,
|
||||
lists,
|
||||
}: ManageSubscriberActionFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`actions.${index}.parameters.lists`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Lists</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const newLists = [...(field.value || []), parseInt(value)];
|
||||
field.onChange(newLists);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((list) => (
|
||||
<SelectItem key={list.id} value={list.id.toString()}>
|
||||
{list.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{field.value?.map((listId: number) => {
|
||||
const list = lists.find((l) => l.id === listId);
|
||||
return (
|
||||
<Badge key={listId} variant="secondary" className="px-2 py-1">
|
||||
{list ? list.name : `List ${listId}`}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-1 h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
const newLists = field.value.filter(
|
||||
(id: number) => id !== listId
|
||||
);
|
||||
field.onChange(newLists);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FormDescription>
|
||||
Default lists to add this user on creation. (Apart from the lists
|
||||
for the ghost blog newsletters) p.e. "New Subscribers"
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Add more fields specific to manage_subscriber action if needed */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
ui/components/SonCreationForm.tsx
Normal file
119
ui/components/SonCreationForm.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useSons } from "@/hooks/useSons";
|
||||
import { useLists } from "@/hooks/useLists";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
import { useCustomToast } from "@/hooks/useCustomToast";
|
||||
import { SonDetailsForm } from "./SonDetailsForm";
|
||||
import { ActionForm } from "./ActionForm";
|
||||
|
||||
// Import or define your schema here
|
||||
import { sonSchema } from "@/lib/schemas";
|
||||
|
||||
type SonFormValues = z.infer<typeof sonSchema>;
|
||||
|
||||
export default function SonCreationForm() {
|
||||
const { createSon } = useSons();
|
||||
const { showToast } = useCustomToast();
|
||||
const router = useRouter();
|
||||
const { lists, loading: listsLoading, error: listsError } = useLists();
|
||||
const {
|
||||
templates,
|
||||
loading: templatesLoading,
|
||||
error: templatesError,
|
||||
} = useTemplates();
|
||||
|
||||
const form = useForm<SonFormValues>({
|
||||
resolver: zodResolver(sonSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
trigger: "member_created",
|
||||
delay: 0,
|
||||
actions: [
|
||||
{
|
||||
type: "send_transactional_email",
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "actions",
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SonFormValues) => {
|
||||
try {
|
||||
await createSon(data);
|
||||
showToast("Success", "Son created successfully");
|
||||
router.push("/sons");
|
||||
} catch (error) {
|
||||
showToast("Error", "Failed to create Son", "destructive");
|
||||
console.error("Failed to create Son:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (listsLoading || templatesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">Loading...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (listsError || templatesError) {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Error: {listsError?.message || templatesError?.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<SonDetailsForm form={form} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{fields.map((field, index) => (
|
||||
<ActionForm
|
||||
key={field.id}
|
||||
form={form}
|
||||
index={index}
|
||||
remove={remove}
|
||||
lists={lists}
|
||||
templates={templates}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
append({ type: "send_transactional_email", parameters: {} })
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" /> Add Action
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Create Son
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
94
ui/components/SonDetailsForm.tsx
Normal file
94
ui/components/SonDetailsForm.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SonDetailsFormProps } from "@/lib/types";
|
||||
|
||||
export function SonDetailsForm({ form }: SonDetailsFormProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Son Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Son Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter Son name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Give your Son a unique and descriptive name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trigger"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Trigger</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a trigger" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member_created">Member Created</SelectItem>
|
||||
<SelectItem value="member_deleted">Member Deleted</SelectItem>
|
||||
<SelectItem value="member_updated">Member Updated</SelectItem>
|
||||
<SelectItem value="post_published">Post Published</SelectItem>
|
||||
<SelectItem value="post_scheduled">Post Scheduled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the event that will trigger this Son.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (minutes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set a delay before the Son executes its actions.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
102
ui/components/SonList.tsx
Normal file
102
ui/components/SonList.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useSons } from "@/hooks/useSons";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export function SonList() {
|
||||
const { sons, loading, error, deleteSon } = useSons();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading sons...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Sons</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sons.length === 0 && <div>No sons found. Create your first son!</div>}
|
||||
|
||||
{sons.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Trigger</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sons.map((son) => (
|
||||
<TableRow key={son.id}>
|
||||
<TableCell>{son.name}</TableCell>
|
||||
<TableCell>{son.trigger}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Link href={`/sons/${son.id}`}>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will
|
||||
permanently delete the Son.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteSon(son.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
72
ui/components/layout.tsx
Normal file
72
ui/components/layout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ListPlus,
|
||||
ListTree,
|
||||
Settings,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
|
||||
const Sidebar = ({ isOpen }: { isOpen: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const menuItems = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/sons/new", label: "Create Son", icon: ListPlus },
|
||||
{ href: "/sons", label: "Manage Sons", icon: ListTree },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-gray-800 text-white w-64 min-h-screen p-4 ${
|
||||
isOpen ? "" : "hidden"
|
||||
} md:block`}
|
||||
>
|
||||
<nav>
|
||||
<ul>
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.href} className="mb-2">
|
||||
<Link href={item.href}>
|
||||
<Button
|
||||
variant={pathname === item.href ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar isOpen={isSidebarOpen} />
|
||||
<div className="flex-1">
|
||||
<header className="bg-white shadow p-4 flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="md:hidden"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-bold">Ghost-Listmonk Connector</h1>
|
||||
</header>
|
||||
<main className="p-4">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
ui/components/ui/alert-dialog.tsx
Normal file
141
ui/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
ui/components/ui/alert.tsx
Normal file
59
ui/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
36
ui/components/ui/badge.tsx
Normal file
36
ui/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
ui/components/ui/button.tsx
Normal file
56
ui/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
ui/components/ui/card.tsx
Normal file
79
ui/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
365
ui/components/ui/chart.tsx
Normal file
365
ui/components/ui/chart.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
178
ui/components/ui/form.tsx
Normal file
178
ui/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
25
ui/components/ui/input.tsx
Normal file
25
ui/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
ui/components/ui/label.tsx
Normal file
26
ui/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
160
ui/components/ui/select.tsx
Normal file
160
ui/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
31
ui/components/ui/separator.tsx
Normal file
31
ui/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
29
ui/components/ui/switch.tsx
Normal file
29
ui/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
ui/components/ui/table.tsx
Normal file
117
ui/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
24
ui/components/ui/textarea.tsx
Normal file
24
ui/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
129
ui/components/ui/toast.tsx
Normal file
129
ui/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
ui/components/ui/toaster.tsx
Normal file
35
ui/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
194
ui/components/ui/use-toast.ts
Normal file
194
ui/components/ui/use-toast.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
15
ui/hooks/useCustomToast.ts
Normal file
15
ui/hooks/useCustomToast.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function useCustomToast() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const showToast = (title: string, description: string, variant: "default" | "destructive" = "default") => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
return { showToast }
|
||||
}
|
||||
29
ui/hooks/useLists.ts
Normal file
29
ui/hooks/useLists.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { ListmonkList } from '@/lib/types';
|
||||
|
||||
|
||||
export function useLists() {
|
||||
const [lists, setLists] = useState<ListmonkList[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchLists = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get<{ data: ListmonkList[] }>('/lists');
|
||||
setLists(response.data.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLists();
|
||||
}, [fetchLists]);
|
||||
|
||||
return { lists, loading, error, fetchLists };
|
||||
}
|
||||
31
ui/hooks/useSon.ts
Normal file
31
ui/hooks/useSon.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
|
||||
// src/hooks/useSon.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { Son } from '@/lib/types';
|
||||
|
||||
export function useSon(id: string) {
|
||||
const [son, setSon] = useState<Son | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchSon = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get<Son>(`/sons/${id}`);
|
||||
setSon(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSon();
|
||||
}, [fetchSon]);
|
||||
|
||||
return { son, loading, error, fetchSon };
|
||||
}
|
||||
75
ui/hooks/useSons.ts
Normal file
75
ui/hooks/useSons.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
|
||||
// src/hooks/useSons.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { Son } from '@/lib/types';
|
||||
|
||||
|
||||
export function useSons() {
|
||||
const [sons, setSons] = useState<Son[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchSons = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get<Son[]>('/sons');
|
||||
setSons(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createSon = useCallback(async (sonData: Omit<Son, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post<Son>('/sons', sonData);
|
||||
setSons(prevSons => [...prevSons, response.data]);
|
||||
setError(null);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSon = useCallback(async (id: string, sonData: Partial<Son>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.put<Son>(`/sons/${id}`, sonData);
|
||||
setSons(prevSons => prevSons.map(son => son.id === id ? response.data : son));
|
||||
setError(null);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteSon = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.delete(`/sons/${id}`);
|
||||
setSons(prevSons => prevSons.filter(son => son.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSons();
|
||||
}, [fetchSons]);
|
||||
|
||||
return { sons, loading, error, fetchSons, createSon, updateSon, deleteSon };
|
||||
}
|
||||
30
ui/hooks/useTemplates.ts
Normal file
30
ui/hooks/useTemplates.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { ListmonkTemplate } from '@/lib/types';
|
||||
|
||||
|
||||
|
||||
export function useTemplates() {
|
||||
const [templates, setTemplates] = useState<ListmonkTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get<{ data: ListmonkTemplate[] }>('/templates');
|
||||
setTemplates(response.data.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An error occurred'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
return { templates, loading, error, fetchTemplates };
|
||||
}
|
||||
14
ui/lib/api-client.ts
Normal file
14
ui/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// src/lib/api-client.ts
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8808';
|
||||
const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "your-api-key";
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL.replace(/\/$/, ''), // Remove trailing slash if present
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
64
ui/lib/schemas.ts
Normal file
64
ui/lib/schemas.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as z from 'zod';
|
||||
|
||||
// Define the schema for action parameters
|
||||
const actionParametersSchema = z.object({
|
||||
subject: z.string().optional(),
|
||||
lists: z.array(z.number()).optional(),
|
||||
template_id: z.number().optional(),
|
||||
tags: z.string().optional(),
|
||||
send_now: z.boolean().optional(),
|
||||
}).strict().or(z.record(z.any())); // Allow any other properties for flexibility
|
||||
|
||||
|
||||
|
||||
// Define the schema for a single action
|
||||
const actionSchema = z.object({
|
||||
type: z.enum(['send_transactional_email', 'manage_subscriber', 'create_campaign']),
|
||||
parameters: actionParametersSchema,
|
||||
});
|
||||
|
||||
// Define the schema for the entire Son object
|
||||
export const sonSchema = z.object({
|
||||
id: z.string().optional(), // Optional because it might not be present when creating a new Son
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
trigger: z.enum([
|
||||
'member_created',
|
||||
'member_deleted',
|
||||
'member_updated',
|
||||
'post_published',
|
||||
'post_scheduled',
|
||||
]),
|
||||
delay: z.number().min(0, 'Delay must be a positive number'),
|
||||
actions: z.array(actionSchema).min(1, 'At least one action is required'),
|
||||
created_at: z.date().optional(),
|
||||
updated_at: z.date().optional(),
|
||||
});
|
||||
|
||||
export const editableSonSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
trigger: z.enum([
|
||||
'member_created',
|
||||
'member_deleted',
|
||||
'member_updated',
|
||||
'post_published',
|
||||
'post_scheduled',
|
||||
]),
|
||||
delay: z.number().min(0, 'Delay must be a positive number'),
|
||||
actions: z.array(z.object({
|
||||
type: z.enum(['send_transactional_email', 'manage_subscriber', 'create_campaign']),
|
||||
parameters: z.record(z.any()),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
// Define a schema for creating a new Son (without id, createdAt, and updatedAt)
|
||||
export const createSonSchema = sonSchema.omit({ id: true, created_at: true, updated_at: true });
|
||||
|
||||
// Define a schema for updating an existing Son
|
||||
export const updateSonSchema = sonSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
// Define types for create and update operations
|
||||
export type CreateSonInput = z.infer<typeof createSonSchema>;
|
||||
export type UpdateSonInput = z.infer<typeof updateSonSchema>;
|
||||
54
ui/lib/types.ts
Normal file
54
ui/lib/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { UseFormReturn, FieldValues } from 'react-hook-form';
|
||||
import { editableSonSchema } from '@/lib/schemas'; // Assuming this is where your Son type is defined
|
||||
import { z } from 'zod';
|
||||
// Define a type for the Son object based on the schema
|
||||
export interface Son extends z.infer<typeof editableSonSchema> {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Type for editable Son fields
|
||||
export type EditableSon = z.infer<typeof editableSonSchema>;
|
||||
|
||||
export interface ListmonkList {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
optin: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ListmonkTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
// Props for CampaignActionFields
|
||||
export interface CampaignActionFieldsProps {
|
||||
form: UseFormReturn<EditableSon>;
|
||||
index: number;
|
||||
lists: ListmonkList[];
|
||||
templates: ListmonkTemplate[];
|
||||
}
|
||||
|
||||
// Props for ManageSubscriberActionFields
|
||||
export interface ManageSubscriberActionFieldsProps {
|
||||
form: UseFormReturn<EditableSon>;
|
||||
index: number;
|
||||
lists: ListmonkList[];
|
||||
}
|
||||
|
||||
// Props for SonDetailsForm
|
||||
export interface SonDetailsFormProps {
|
||||
form: UseFormReturn<EditableSon>;
|
||||
}
|
||||
|
||||
export interface ActionFormProps {
|
||||
form: UseFormReturn<EditableSon>;
|
||||
index: number;
|
||||
remove: (index: number) => void;
|
||||
lists: ListmonkList[];
|
||||
templates: ListmonkTemplate[];
|
||||
}
|
||||
6
ui/lib/utils.ts
Normal file
6
ui/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
4
ui/next.config.mjs
Normal file
4
ui/next.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
43
ui/package.json
Normal file
43
ui/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "ghost-listmonk-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"axios": "^1.7.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.427.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"recharts": "^2.12.7",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
3890
ui/pnpm-lock.yaml
Normal file
3890
ui/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
8
ui/postcss.config.mjs
Normal file
8
ui/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
ui/public/next.svg
Normal file
1
ui/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
ui/public/vercel.svg
Normal file
1
ui/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
1984
ui/repopack-output-ui.txt
Normal file
1984
ui/repopack-output-ui.txt
Normal file
File diff suppressed because it is too large
Load diff
69
ui/styles/globals.css
Normal file
69
ui/styles/globals.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
80
ui/tailwind.config.ts
Normal file
80
ui/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { Config } from "tailwindcss"
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
||||
26
ui/tsconfig.json
Normal file
26
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
359
ui/webhook-dump.txt
Normal file
359
ui/webhook-dump.txt
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
Purpose:
|
||||
--------
|
||||
This file contains webhook body content sent from the ghost blog when certain events happen.
|
||||
These events will be parsed by the ghost-listmonk-connector and based on the "sons" (think of Zaps in Zapier) sent to listmonk API to manage a mailing list.
|
||||
|
||||
-------
|
||||
Event: user subscribed to newsletter "troneras"
|
||||
|
||||
|
||||
Raw content:
|
||||
|
||||
```json
|
||||
{
|
||||
"member": {
|
||||
"current": {
|
||||
"id": "66b68e4d46c2c0000128b809",
|
||||
"uuid": "c3570109-5572-4ae5-91be-f4bfe2f02653",
|
||||
"email": "connect@radianceflow.com",
|
||||
"name": null,
|
||||
"note": null,
|
||||
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.122.19\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
|
||||
"subscribed": true,
|
||||
"created_at": "2024-08-09T21:46:53.000Z",
|
||||
"updated_at": "2024-08-09T21:46:53.000Z",
|
||||
"labels": [],
|
||||
"subscriptions": [],
|
||||
"avatar_image": "https://www.gravatar.com/avatar/e7e64e923ef0d0a0967445b2b73c7144?s=250&r=g&d=blank",
|
||||
"comped": false,
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null,
|
||||
"status": "free",
|
||||
"last_seen_at": null,
|
||||
"tiers": [],
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "troneras",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
},
|
||||
"previous": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-------
|
||||
Event: User unsubscribed from newsletter "weekly-newsletter", previously he was subscribed to "troneras" and "weekly-newsletter", now he is only subscribed to "troneras"
|
||||
|
||||
Raw content:
|
||||
```json
|
||||
{
|
||||
"member": {
|
||||
"current": {
|
||||
"id": "66b697fb46c2c0000128b812",
|
||||
"uuid": "8cf44986-34a2-42ee-b2c0-27de2a05be5a",
|
||||
"email": "joblazqu@gmail.com",
|
||||
"name": null,
|
||||
"note": null,
|
||||
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.120.135\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
|
||||
"subscribed": true,
|
||||
"created_at": "2024-08-09T22:28:11.000Z",
|
||||
"updated_at": "2024-08-10T11:05:09.000Z",
|
||||
"labels": [],
|
||||
"subscriptions": [],
|
||||
"avatar_image": "https://www.gravatar.com/avatar/638498f10dd417d8ffceb08b33d5bdb0?s=250&r=g&d=blank",
|
||||
"comped": false,
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null,
|
||||
"status": "free",
|
||||
"last_seen_at": "2024-08-10T11:05:09.000Z",
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "troneras",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
},
|
||||
"previous": {
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "troneras",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "66b74a9546c2c0000128b821",
|
||||
"name": "weekly-newsletter",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-------
|
||||
Event: User unsubscribed from all newsletters
|
||||
|
||||
Raw content:
|
||||
```json
|
||||
{
|
||||
"member": {
|
||||
"current": {
|
||||
"id": "66b697fb46c2c0000128b812",
|
||||
"uuid": "8cf44986-34a2-42ee-b2c0-27de2a05be5a",
|
||||
"email": "joblazqu@gmail.com",
|
||||
"name": null,
|
||||
"note": null,
|
||||
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.120.135\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
|
||||
"subscribed": false,
|
||||
"created_at": "2024-08-09T22:28:11.000Z",
|
||||
"updated_at": "2024-08-10T11:14:06.000Z",
|
||||
"labels": [],
|
||||
"subscriptions": [],
|
||||
"avatar_image": "https://www.gravatar.com/avatar/638498f10dd417d8ffceb08b33d5bdb0?s=250&r=g&d=blank",
|
||||
"comped": false,
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null,
|
||||
"status": "free",
|
||||
"last_seen_at": "2024-08-10T11:05:09.000Z",
|
||||
"newsletters": []
|
||||
},
|
||||
"previous": {
|
||||
"updated_at": "2024-08-10T11:05:09.000Z",
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "troneras",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "66b74a9546c2c0000128b821",
|
||||
"name": "weekly-newsletter",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-------
|
||||
Event: a new Post was published
|
||||
|
||||
Raw content:
|
||||
```json
|
||||
{
|
||||
"post": {
|
||||
"current": {
|
||||
"id": "66b86d9546c2c0000128b845",
|
||||
"uuid": "5095d0f5-738d-416b-81b6-c3036779393f",
|
||||
"title": "A third post",
|
||||
"slug": "a-third-post",
|
||||
"mobiledoc": null,
|
||||
"html": null,
|
||||
"comment_id": "66b86d9546c2c0000128b845",
|
||||
"feature_image": null,
|
||||
"featured": false,
|
||||
"status": "draft",
|
||||
"visibility": "public",
|
||||
"created_at": "2024-08-11T07:51:49.000Z",
|
||||
"updated_at": "2024-08-11T07:51:49.000Z",
|
||||
"published_at": null,
|
||||
"custom_excerpt": null,
|
||||
"codeinjection_head": null,
|
||||
"codeinjection_foot": null,
|
||||
"custom_template": null,
|
||||
"canonical_url": null,
|
||||
"tags": [],
|
||||
"authors": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Antonio Blázquez",
|
||||
"slug": "antonio",
|
||||
"email": "antonio@troneras.com",
|
||||
"profile_image": null,
|
||||
"cover_image": null,
|
||||
"bio": null,
|
||||
"website": null,
|
||||
"location": null,
|
||||
"facebook": null,
|
||||
"twitter": null,
|
||||
"accessibility": "{\"onboarding\":{\"completedSteps\":[],\"checklistState\":\"dismissed\"},\"whatsNew\":{\"lastSeenDate\":\"2024-06-25T13:42:34.000+00:00\"}}",
|
||||
"status": "active",
|
||||
"meta_title": null,
|
||||
"meta_description": null,
|
||||
"tour": null,
|
||||
"last_seen": "2024-08-11T07:49:36.000Z",
|
||||
"comment_notifications": true,
|
||||
"free_member_signup_notification": true,
|
||||
"paid_subscription_started_notification": true,
|
||||
"paid_subscription_canceled_notification": false,
|
||||
"mention_notifications": true,
|
||||
"recommendation_notifications": true,
|
||||
"milestone_notifications": true,
|
||||
"donation_notifications": true,
|
||||
"created_at": "2024-08-09T16:59:59.000Z",
|
||||
"updated_at": "2024-08-11T07:49:36.000Z",
|
||||
"url": "https://ghost.troneras.com/author/antonio/"
|
||||
}
|
||||
],
|
||||
"post_revisions": [
|
||||
{
|
||||
"id": "66b86d9546c2c0000128b847",
|
||||
"post_id": "66b86d9546c2c0000128b845",
|
||||
"lexical": "{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}",
|
||||
"created_at_ts": 1723362709338,
|
||||
"created_at": "2024-08-11T07:51:49.000Z",
|
||||
"title": "A third post",
|
||||
"post_status": "draft",
|
||||
"reason": "initial_revision",
|
||||
"feature_image": null,
|
||||
"feature_image_alt": null,
|
||||
"feature_image_caption": null,
|
||||
"custom_excerpt": null
|
||||
}
|
||||
],
|
||||
"tiers": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9841",
|
||||
"name": "Free",
|
||||
"slug": "free",
|
||||
"active": true,
|
||||
"welcome_page_url": null,
|
||||
"visibility": "public",
|
||||
"trial_days": 0,
|
||||
"description": "some description",
|
||||
"type": "free",
|
||||
"currency": null,
|
||||
"monthly_price": null,
|
||||
"yearly_price": null,
|
||||
"created_at": "2024-08-09T17:00:00.000Z",
|
||||
"updated_at": "2024-08-09T17:00:00.000Z",
|
||||
"monthly_price_id": null,
|
||||
"yearly_price_id": null
|
||||
},
|
||||
{
|
||||
"id": "66b64b106243d80001ac9842",
|
||||
"name": "troneras",
|
||||
"slug": "default-product",
|
||||
"active": true,
|
||||
"welcome_page_url": null,
|
||||
"visibility": "public",
|
||||
"trial_days": 0,
|
||||
"description": null,
|
||||
"type": "paid",
|
||||
"currency": "usd",
|
||||
"monthly_price": 500,
|
||||
"yearly_price": 5000,
|
||||
"created_at": "2024-08-09T17:00:00.000Z",
|
||||
"updated_at": "2024-08-09T17:01:16.000Z",
|
||||
"monthly_price_id": null,
|
||||
"yearly_price_id": null
|
||||
}
|
||||
],
|
||||
"primary_author": {
|
||||
"id": "1",
|
||||
"name": "Antonio Blázquez",
|
||||
"slug": "antonio",
|
||||
"email": "antonio@troneras.com",
|
||||
"profile_image": null,
|
||||
"cover_image": null,
|
||||
"bio": null,
|
||||
"website": null,
|
||||
"location": null,
|
||||
"facebook": null,
|
||||
"twitter": null,
|
||||
"accessibility": "{\"onboarding\":{\"completedSteps\":[],\"checklistState\":\"dismissed\"},\"whatsNew\":{\"lastSeenDate\":\"2024-06-25T13:42:34.000+00:00\"}}",
|
||||
"status": "active",
|
||||
"meta_title": null,
|
||||
"meta_description": null,
|
||||
"tour": null,
|
||||
"last_seen": "2024-08-11T07:49:36.000Z",
|
||||
"comment_notifications": true,
|
||||
"free_member_signup_notification": true,
|
||||
"paid_subscription_started_notification": true,
|
||||
"paid_subscription_canceled_notification": false,
|
||||
"mention_notifications": true,
|
||||
"recommendation_notifications": true,
|
||||
"milestone_notifications": true,
|
||||
"donation_notifications": true,
|
||||
"created_at": "2024-08-09T16:59:59.000Z",
|
||||
"updated_at": "2024-08-11T07:49:36.000Z",
|
||||
"url": "https://ghost.troneras.com/author/antonio/"
|
||||
},
|
||||
"primary_tag": null,
|
||||
"email_segment": "all",
|
||||
"url": "https://ghost.troneras.com/p/5095d0f5-738d-416b-81b6-c3036779393f/",
|
||||
"excerpt": null,
|
||||
"og_image": null,
|
||||
"og_title": null,
|
||||
"og_description": null,
|
||||
"twitter_image": null,
|
||||
"twitter_title": null,
|
||||
"twitter_description": null,
|
||||
"meta_title": null,
|
||||
"meta_description": null,
|
||||
"email_subject": null,
|
||||
"frontmatter": null,
|
||||
"feature_image_alt": null,
|
||||
"feature_image_caption": null,
|
||||
"email_only": false
|
||||
},
|
||||
"previous": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-------
|
||||
Event: member deleted
|
||||
|
||||
Raw content:
|
||||
```json
|
||||
{
|
||||
"member": {
|
||||
"current": {},
|
||||
"previous": {
|
||||
"newsletters": [
|
||||
{
|
||||
"id": "66b64b106243d80001ac9843",
|
||||
"name": "rweqesfdasdf",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "66b74a9546c2c0000128b821",
|
||||
"name": "weekly-newsletter",
|
||||
"description": null,
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"id": "66b68e4d46c2c0000128b809",
|
||||
"uuid": "c3570109-5572-4ae5-91be-f4bfe2f02653",
|
||||
"email": "connect@radianceflow.com",
|
||||
"status": "free",
|
||||
"name": null,
|
||||
"note": null,
|
||||
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.122.19\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
|
||||
"email_count": 0,
|
||||
"email_opened_count": 0,
|
||||
"email_open_rate": null,
|
||||
"last_seen_at": "2024-08-09T21:46:54.000Z",
|
||||
"created_at": "2024-08-09T21:46:53.000Z",
|
||||
"updated_at": "2024-08-09T21:46:54.000Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
83
utils/config.go
Normal file
83
utils/config.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all configuration values
|
||||
type Config struct {
|
||||
ListmonkURL string
|
||||
APIKey string
|
||||
Port string
|
||||
// Add other configuration fields as needed
|
||||
}
|
||||
|
||||
// LoadConfig reads configuration from .env.local file and environment variables
|
||||
func LoadConfig() (*Config, error) {
|
||||
config := &Config{}
|
||||
|
||||
// Read from .env.local file
|
||||
if err := loadEnvFile(".env.local", config); err != nil {
|
||||
ErrorLogger.Printf("Error loading .env.local file: %v", err)
|
||||
// Continue execution, as we'll fall back to environment variables
|
||||
}
|
||||
|
||||
// Override with environment variables if they exist
|
||||
if envListmonkURL := os.Getenv("LISTMONK_URL"); envListmonkURL != "" {
|
||||
config.ListmonkURL = envListmonkURL
|
||||
}
|
||||
if envAPIKey := os.Getenv("API_KEY"); envAPIKey != "" {
|
||||
config.APIKey = envAPIKey
|
||||
}
|
||||
|
||||
if envPort := os.Getenv("API_KEY"); envPort != "" {
|
||||
config.Port = envPort
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if config.ListmonkURL == "" {
|
||||
return nil, fmt.Errorf("LISTMONK_URL is not set")
|
||||
}
|
||||
if config.APIKey == "" {
|
||||
return nil, fmt.Errorf("API_KEY is not set")
|
||||
}
|
||||
if config.Port == "" {
|
||||
config.Port = "8808"
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func loadEnvFile(filename string, config *Config) error {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "LISTMONK_URL":
|
||||
config.ListmonkURL = value
|
||||
case "API_KEY":
|
||||
config.APIKey = value
|
||||
case "PORT":
|
||||
config.Port = value
|
||||
// Add other configuration fields as needed
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
19
utils/errors.go
Normal file
19
utils/errors.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
type CustomError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *CustomError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func NewError(code, message string) *CustomError {
|
||||
return &CustomError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
28
utils/logger.go
Normal file
28
utils/logger.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
*log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
InfoLogger *Logger
|
||||
ErrorLogger *Logger
|
||||
)
|
||||
|
||||
func init() {
|
||||
InfoLogger = &Logger{log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)}
|
||||
ErrorLogger = &Logger{log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)}
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, v ...interface{}) {
|
||||
l.Printf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, v ...interface{}) {
|
||||
l.Printf(format, v...)
|
||||
}
|
||||
13
utils/pretty_print.go
Normal file
13
utils/pretty_print.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func PrettyPrint(v interface{}) string {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
9
utils/uuid.go
Normal file
9
utils/uuid.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func GenerateUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
Loading…
Reference in a new issue