Add frontend React dashboard with CI/CD deployment pipeline
Some checks failed
Build and Deploy API Server & Frontend / deploy (push) Blocked by required conditions
Build and Deploy API Server & Frontend / build-backend (push) Has been cancelled
Build and Deploy API Server & Frontend / build-frontend (push) Has been cancelled

This commit is contained in:
Farid Siddiqi 2025-12-25 22:08:03 -08:00
parent f6833616ff
commit a1360919f0
63 changed files with 13291 additions and 81 deletions

View File

@ -1,4 +1,4 @@
name: Build and Deploy API Server
name: Build and Deploy API Server & Frontend
on:
push:
@ -12,9 +12,10 @@ on:
env:
ARTIFACT_DIR: /opt/api-artifacts
GO_VERSION: '1.24'
NODE_VERSION: '20'
jobs:
build:
build-backend:
runs-on: ubuntu-latest
steps:
@ -39,13 +40,16 @@ jobs:
echo "github output is $GITHUB_OUTPUT"
- name: Download dependencies
working-directory: ./backend
run: go mod download
# takes too long, investigate why it takes so long
# - name: Run tests
# working-directory: ./backend
# run: go test -v ./...
- name: Build binary
working-directory: ./backend
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${{ steps.commit.outputs.short_sha }} -X main.BuildTime=${{ steps.commit.outputs.date }}" \
@ -67,8 +71,8 @@ jobs:
- name: Package artifact
run: |
mkdir -p artifact-package
cp api-server artifact-package/
cp -r migrations artifact-package/
cp backend/api-server artifact-package/
cp -r backend/migrations artifact-package/
cp build-info.json artifact-package/
tar -czf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C artifact-package .
@ -79,8 +83,49 @@ jobs:
path: api-server-${{ steps.commit.outputs.sha }}.tar.gz
retention-days: 30
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Get commit info
id: commit
run: |
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Build frontend
working-directory: ./frontend
env:
VITE_API_URL: https://api.nitrokite.com
run: npm run build
- name: Create frontend artifact
run: |
tar -czf frontend-${{ steps.commit.outputs.sha }}.tar.gz -C frontend/dist .
- name: Upload frontend artifact
uses: actions/upload-artifact@v3
with:
name: frontend-${{ steps.commit.outputs.sha }}
path: frontend-${{ steps.commit.outputs.sha }}.tar.gz
retention-days: 30
deploy:
needs: build
needs: [build-backend, build-frontend]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
@ -95,12 +140,17 @@ jobs:
id: commit
run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Download artifact
- name: Download backend artifact
uses: actions/download-artifact@v3
with:
name: api-server-${{ steps.commit.outputs.sha }}
- name: Extract and deploy
- name: Download frontend artifact
uses: actions/download-artifact@v3
with:
name: frontend-${{ steps.commit.outputs.sha }}
- name: Extract and deploy backend
run: |
BUILD_DIR="${{ env.ARTIFACT_DIR }}/builds/${{ steps.commit.outputs.sha }}"
mkdir -p "$BUILD_DIR"
@ -113,6 +163,13 @@ jobs:
cp "${{ env.ARTIFACT_DIR }}/config/.env.production" "$BUILD_DIR/.env.production"
fi
- name: Extract and deploy frontend
run: |
FRONTEND_STAGING_DIR="/opt/frontend-artifacts/staging/${{ steps.commit.outputs.sha }}"
mkdir -p "$FRONTEND_STAGING_DIR"
tar -xzf frontend-${{ steps.commit.outputs.sha }}.tar.gz -C "$FRONTEND_STAGING_DIR"
chown -R www-data:www-data "$FRONTEND_STAGING_DIR"
- name: Run deployment script
run: |
cd ${{ env.ARTIFACT_DIR }}/scripts
@ -126,5 +183,8 @@ jobs:
if: always()
run: |
echo "Deployment staged successfully: ${{ steps.commit.outputs.sha }}"
echo "Backend staged at: /opt/api-artifacts/builds/${{ steps.commit.outputs.sha }}"
echo "Frontend staged at: /opt/frontend-artifacts/staging/${{ steps.commit.outputs.sha }}"
echo ""
echo "To promote to production, SSH to server and run:"
echo " sudo /opt/api-artifacts/scripts/promote.sh"
echo " sudo /opt/api-artifacts/scripts/promote.sh ${{ steps.commit.outputs.sha }}"

View File

@ -0,0 +1,190 @@
name: Build and Deploy API Server & Frontend
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
env:
ARTIFACT_DIR: /opt/api-artifacts
GO_VERSION: '1.24'
NODE_VERSION: '20'
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: false
- name: Get commit info
id: commit
run: |
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
echo "github output is $GITHUB_OUTPUT"
- name: Download dependencies
working-directory: ./backend
run: go mod download
# takes too long, investigate why it takes so long
# - name: Run tests
# working-directory: ./backend
# run: go test -v ./...
- name: Build binary
working-directory: ./backend
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${{ steps.commit.outputs.short_sha }} -X main.BuildTime=${{ steps.commit.outputs.date }}" \
-o api-server \
.
- name: Create build metadata
run: |
cat > build-info.json << EOF
{
"commit": "${{ steps.commit.outputs.sha }}",
"short_commit": "${{ steps.commit.outputs.short_sha }}",
"branch": "${{ steps.commit.outputs.branch }}",
"build_date": "${{ steps.commit.outputs.date }}",
"go_version": "${{ env.GO_VERSION }}"
}
EOF
- name: Package artifact
run: |
mkdir -p artifact-package
cp backend/api-server artifact-package/
cp -r backend/migrations artifact-package/
cp build-info.json artifact-package/
tar -czf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C artifact-package .
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: api-server-${{ steps.commit.outputs.sha }}
path: api-server-${{ steps.commit.outputs.sha }}.tar.gz
retention-days: 30
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Get commit info
id: commit
run: |
echo "sha=$(gbackend artifact
uses: actions/download-artifact@v3
with:
name: api-server-${{ steps.commit.outputs.sha }}
- name: Download frontend artifact
uses: actions/download-artifact@v3
with:
name: frontend-${{ steps.commit.outputs.sha }}
- name: Extract and deploy backend
run: |
BUILD_DIR="${{ env.ARTIFACT_DIR }}/builds/${{ steps.commit.outputs.sha }}"
mkdir -p "$BUILD_DIR"
tar -xzf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C "$BUILD_DIR"
chmod +x "$BUILD_DIR/api-server"
if [ -f "${{ env.ARTIFACT_DIR }}/config/.env.production" ]; then
cp "${{ env.ARTIFACT_DIR }}/config/.env.production" "$BUILD_DIR/.env.production"
fi
- name: Extract and deploy frontend
run: |
FRONTEND_STAGING_DIR="/opt/frontend-artifacts/staging/${{ steps.commit.outputs.sha }}"
mkdir -p "$FRONTEND_STAGING_DIR"
tar -xzf frontend-${{ steps.commit.outputs.sha }}.tar.gz -C "$FRONTEND_STAGING_DIR"
chown -R www-data:www-data "$FRONTEND_STAGING_DIR"
- name: Upload frontend artifact
uses: actions/upload-artifact@v3
with:Backend staged at: /opt/api-artifacts/builds/${{ steps.commit.outputs.sha }}"
echo "Frontend staged at: /opt/frontend-artifacts/staging/${{ steps.commit.outputs.sha }}"
echo ""
echo "To promote to production, SSH to server and run:"
echo " sudo /opt/api-artifacts/scripts/promote.sh ${{ steps.commit.outputs.sha }}
path: frontend-${{ steps.commit.outputs.sha }}.tar.gz
retention-days: 30
deploy:
needs: [build-backend, build-frontend]
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout deployment scripts
uses: actions/checkout@v4
with:
sparse-checkout: |
deployment/scripts
- name: Get commit SHA
id: commit
run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: api-server-${{ steps.commit.outputs.sha }}
- name: Extract and deploy
run: |
BUILD_DIR="${{ env.ARTIFACT_DIR }}/builds/${{ steps.commit.outputs.sha }}"
mkdir -p "$BUILD_DIR"
tar -xzf api-server-${{ steps.commit.outputs.sha }}.tar.gz -C "$BUILD_DIR"
chmod +x "$BUILD_DIR/api-server"
if [ -f "${{ env.ARTIFACT_DIR }}/config/.env.production" ]; then
cp "${{ env.ARTIFACT_DIR }}/config/.env.production" "$BUILD_DIR/.env.production"
fi
- name: Run deployment script
run: |
cd ${{ env.ARTIFACT_DIR }}/scripts
./deploy.sh ${{ steps.commit.outputs.sha }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AUTO_MIGRATE: "false"
PORT: "8080"
- name: Notify deployment status
if: always()
run: |
echo "Deployment staged successfully: ${{ steps.commit.outputs.sha }}"
echo "To promote to production, SSH to server and run:"
echo " sudo /opt/api-artifacts/scripts/promote.sh"

2
.gitignore vendored
View File

@ -9,6 +9,8 @@ tmp/
*.dylib
*.test
*.out
api-server
backend/api-server
# Go workspace file
go.work

1
PROJECT_DESCRIPTION.md Normal file
View File

@ -0,0 +1 @@
NitroKite is a project that will allow customers to fully manage their

112
README.md Normal file
View File

@ -0,0 +1,112 @@
# Nitrokite
Complete digital marketing platform for small businesses. Website creation, SEO tracking, online booking, social media content generation, review management, and customer feedback - all in one place.
## Project Structure
```
nitrokite/
├── backend/ # Go/Gin API server
│ ├── main.go
│ ├── go.mod
│ └── migrations/ # Database migrations
├── marketing/ # Astro marketing website
│ ├── src/
│ └── dist/ # Built static files
├── deployment/ # Infrastructure & deployment
│ ├── ansible/ # Server configuration
│ └── scripts/ # Deployment automation
└── .gitea/
└── workflows/ # CI/CD pipelines
```
## Services
### Marketing Website
- **URL**: https://nitrokite.com
- **Tech**: Astro static site generator
- **Location**: `marketing/`
- [Marketing README](marketing/README.md)
### API Server
- **URL**: https://api.nitrokite.com
- **Tech**: Go + Gin + PostgreSQL
- **Location**: `backend/`
- [Backend README](backend/README.md)
### Git Server
- **URL**: https://git.nitrokite.com
- **Tech**: Gitea with 5 CI/CD runners
### Application
- **URL**: https://app.nitrokite.com
- **Tech**: TBD (SPA placeholder)
### Monitoring
- **URL**: https://grafana.nitrokite.com
- **Tech**: Grafana
## Quick Start
### Backend Development
```bash
cd backend
go mod download
air # Live reload
```
### Marketing Development
```bash
cd marketing
npm install
npm run dev # http://localhost:4321
```
### Deployment
```bash
cd deployment/ansible
ansible-playbook -i hosts.ini site.yml
```
## Infrastructure
- **Server**: Ubuntu 24.04 on DigitalOcean
- **SSL**: Let's Encrypt certificates (auto-renewal)
- **Reverse Proxy**: Nginx with HTTP/2
- **Database**: PostgreSQL 14
- **CI/CD**: Gitea Actions with 5 runners
- **Deployment**: Blue-green with automated rollback
## Development Workflow
1. **Feature Development**
- Create branch from `main`
- Develop locally with live reload
- Push to feature branch
2. **Testing**
- CI runs tests automatically
- Manual testing on feature branch
3. **Deployment**
- Merge to `main`
- CI builds and stages deployment
- Manual promotion to production
- Automatic rollback on failure
## Monitoring & Operations
- **Health Checks**: Automated via deployment scripts
- **Logs**: `journalctl -u api-server -f`
- **Metrics**: Grafana dashboards
- **SSL Renewal**: Automatic via cron
## Documentation
- [Deployment Guide](deployment/README.md)
- [Backend API](backend/README.md)
- [Marketing Site](marketing/README.md)
## License
Copyright © 2025 Nitrokite. All rights reserved.

108
backend/README.md Normal file
View File

@ -0,0 +1,108 @@
# Nitrokite API Server
Go/Gin REST API server for Nitrokite's digital marketing platform.
## Project Structure
```
backend/
├── main.go # Main application entry point
├── go.mod # Go module dependencies
├── go.sum # Dependency checksums
├── .air.toml # Air live reload configuration
└── migrations/ # Database migration files
├── *.up.sql # Migration up scripts
└── *.down.sql # Migration down scripts
```
## Development
### Prerequisites
- Go 1.24.1 or higher
- PostgreSQL 14+
- Air (for live reload) - `go install github.com/air-verse/air@latest`
### Setup
1. Install dependencies:
```bash
go mod download
```
2. Set up environment variables:
```bash
export DATABASE_URL="host=localhost port=5432 user=apiserver password=apiserver dbname=apiserver sslmode=disable"
export PORT=8080
```
3. Run with live reload:
```bash
air
```
4. Or build and run:
```bash
go build -o api-server .
./api-server
```
## Database Migrations
Migrations are automatically run on application startup. They are located in the `migrations/` directory.
To create a new migration:
```bash
# Create migration files manually with naming convention:
# 000002_description.up.sql
# 000002_description.down.sql
```
## API Endpoints
### Health Check
```
GET /health
```
Returns server health status and version information.
### Users
```
GET /api/v1/users # List all users
GET /api/v1/users/:id # Get user by ID
POST /api/v1/users # Create new user
PUT /api/v1/users/:id # Update user
DELETE /api/v1/users/:id # Delete user
```
## Building for Production
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o api-server \
.
```
## Deployment
The API server is deployed via CI/CD pipeline using blue-green deployment strategy.
See [deployment documentation](../deployment/README.md) for details.
## Environment Variables
- `DATABASE_URL` - PostgreSQL connection string (required)
- `PORT` - Server port (default: 8080)
- `AUTO_MIGRATE` - Run migrations on startup (default: true)
- `GIN_MODE` - Gin mode: debug, release (default: debug)
## Testing
```bash
go test -v ./...
```
## License
Copyright © 2025 Nitrokite. All rights reserved.

23
backend/build.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
set -e
echo "🔨 Building Nitrokite API Server..."
# Navigate to backend directory
cd "$(dirname "$0")"
# Get build information
COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Build the binary
echo "📦 Building binary for Linux..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${COMMIT_SHA} -X main.BuildTime=${BUILD_TIME}" \
-o api-server \
.
echo "✅ Build complete!"
echo " Binary: api-server"
echo " Version: ${COMMIT_SHA}"
echo " Build Time: ${BUILD_TIME}"

View File

@ -2,23 +2,26 @@ module golang-api-server
go 1.24.1
require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/lib/pq v1.10.9
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.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.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect

View File

@ -1,17 +1,45 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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=
@ -22,8 +50,12 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@ -35,13 +67,27 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
@ -53,45 +99,44 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=

View File

@ -1,4 +1,4 @@
# MemberSyncPro Deployment
# NitroKite Deployment
Simple deployment workflow using Ansible + Gitea Actions + Blue-Green deployment.
@ -7,20 +7,46 @@ Simple deployment workflow using Ansible + Gitea Actions + Blue-Green deployment
```
┌──────────────┐
│ 1. Ansible │ Run once to set up infrastructure
│ Setup │ (Gitea, PostgreSQL, Runners, Scripts)
│ Setup │ (Gitea, PostgreSQL, Runners, Nginx, Scripts)
└──────┬───────┘
┌──────▼───────┐
│ 2. Push Code │ Push to Gitea triggers automatic build
│ to Gitea │ and staging
│ to Gitea │ - Backend (Go API)
│ │ - Frontend (React App)
└──────┬───────┘
┌──────▼───────┐
│ 3. Promote │ Manual promotion to production
│ to Prod │ (sudo promote.sh)
│ to Prod │ (sudo promote.sh <commit-sha>)
└──────────────┘
```
## Architecture
### Deployed Applications
1. **Marketing Website** - Static Astro site
- Domain: https://nitrokite.com
- Directory: `/var/www/html/`
- Deployment: Manual via Ansible `synchronize` task
2. **Frontend App** - React TypeScript dashboard
- Domain: https://app.nitrokite.com
- Directory: `/var/www/app/`
- Deployment: Automated via Gitea Actions → staging → promotion
- Features: TanStack Query, Router, Forms, shadcn/ui
3. **Backend API** - Go Gin server
- Domain: https://api.nitrokite.com
- Directory: `/opt/api-artifacts/current/`
- Deployment: Blue-Green with systemd service
- Database: PostgreSQL with separate user/database
4. **Gitea** - Git hosting + CI/CD
- Domain: https://git.nitrokite.com
- Runners: 5 parallel runners in Docker containers
## One-Time Setup
### 1. Prepare Ansible Inventory
@ -72,7 +98,7 @@ This sets up:
### 5. Add Git Remote
```bash
git remote add production http://your-vps-ip/your-username/membersyncpro.git
git remote add production http://your-vps-ip/your-username/nitrokite.git
```
## Daily Deployment Workflow
@ -80,24 +106,34 @@ git remote add production http://your-vps-ip/your-username/membersyncpro.git
### Deploy New Version
```bash
# 1. Push code
# 1. Push code (triggers both backend & frontend builds)
git push production main
# 2. Wait for build (check Gitea → Actions)
# - Builds binary
# - Runs tests
# - Stages to inactive slot (blue/green)
# - Builds Go backend binary
# - Builds React frontend bundle
# - Stages backend to inactive slot (blue/green)
# - Stages frontend to /opt/frontend-artifacts/staging/<commit-sha>
# 3. SSH to VPS and promote
# 3. SSH to VPS and promote (pass the commit SHA)
ssh ubuntu@your-vps-ip
sudo /opt/api-artifacts/scripts/promote.sh
sudo /opt/api-artifacts/scripts/promote.sh <commit-sha>
# Type 'yes' to confirm
# This will:
# - Switch backend to new version (systemd reload)
# - Deploy frontend to /var/www/app/ (nginx serves it)
# - Run health checks
# - Backup previous versions
# 4. Verify
curl http://localhost:8080/hello
curl http://localhost:8080/version
curl https://api.nitrokite.com/hello
curl https://api.nitrokite.com/version
curl https://app.nitrokite.com # Frontend should load
```
**Note:** Get the commit SHA from the Gitea Actions output or via `git rev-parse HEAD`
### Rollback
If something goes wrong after promotion:
@ -107,26 +143,46 @@ ssh ubuntu@your-vps-ip
sudo /opt/api-artifacts/scripts/rollback.sh
```
Instantly reverts to the previous deployment.
Instantly reverts the backend to the previous deployment. For frontend, restore from backup:
```bash
# List backups
ls -l /opt/frontend-artifacts/backups/
# Restore specific backup
sudo rm -rf /var/www/app/*
sudo cp -r /opt/frontend-artifacts/backups/<timestamp>/* /var/www/app/
sudo chown -R www-data:www-data /var/www/app
```
## VPS Directory Structure
```
/opt/api-artifacts/
├── builds/
│ ├── abc123.../ # Previous build
│ └── def456.../ # Current build
│ ├── abc123.../ # Previous backend build
│ └── def456.../ # Current backend build
├── blue -> builds/abc123/ # Previous deployment
├── green -> builds/def456/ # Current deployment
├── current -> green # Active (systemd uses this)
├── scripts/
│ ├── deploy.sh # Auto-called by CI
│ ├── promote.sh # Manual promotion
│ ├── promote.sh # Manual promotion (now handles frontend)
│ ├── rollback.sh # Emergency rollback
│ ├── health-check.sh # Health validation
│ └── migrate.sh # Database migrations
└── config/
└── .env.production # Environment variables
/opt/frontend-artifacts/
├── staging/
│ ├── abc123.../ # Built frontend for commit abc123
│ └── def456.../ # Built frontend for commit def456
└── backups/
└── 20251225_120000/ # Backup of previous frontend
/var/www/
├── html/ # Marketing website (nitrokite.com)
└── app/ # Frontend app (app.nitrokite.com)
```
## Common Tasks
@ -134,9 +190,13 @@ Instantly reverts to the previous deployment.
### View Service Logs
```bash
# Follow logs
# Backend API logs
journalctl -u api-server -f
# Nginx logs (frontend & marketing)
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
# Last 100 lines
journalctl -u api-server -n 100
```
@ -144,11 +204,17 @@ journalctl -u api-server -n 100
### Check Active Deployment
```bash
# See which slot is active
# Backend: See which slot is active
readlink /opt/api-artifacts/current
# Full build path
# Backend: Full build path
readlink -f /opt/api-artifacts/current
# Frontend: Check current files
ls -lh /var/www/app/
# Frontend: Check staging builds available
ls -l /opt/frontend-artifacts/staging/
```
### Check Runners

View File

@ -14,8 +14,9 @@
gitea_http_addr: "0.0.0.0" # Listen on all interfaces
gitea_http_port: 3000
gitea_domain: "{{ ansible_host }}" # Use the server's IP address
gitea_root_url: "http://{{ ansible_host }}" # HTTP with IP and port
gitea_domain: "git.nitrokite.com"
gitea_root_url: "https://{{ gitea_domain }}" # HTTPS with domain
gitea_runner_instance_url: "http://localhost:3000" # Runners connect via localhost to avoid external network
gitea_internal_token: "5982634986925861987263497269412869416237419264" # Change to a secure random token
@ -54,6 +55,8 @@
- postgresql-contrib
- python3-psycopg # Important on Ubuntu 24.04
- docker.io
- certbot
- python3-certbot-nginx
state: present
- name: Start and enable Docker
@ -253,7 +256,7 @@
name: "gitea-runner-{{ item }}"
state: stopped
enabled: false
loop: "{{ range(1, 21) | list }}" # Check up to 20 possible runners
loop: "{{ range(1, runner_count + 1) | list }}"
ignore_errors: true
failed_when: false
@ -261,7 +264,7 @@
ansible.builtin.file:
path: "/etc/systemd/system/gitea-runner-{{ item }}.service"
state: absent
loop: "{{ range(1, 21) | list }}"
loop: "{{ range(1, runner_count + 1) | list }}"
ignore_errors: true
- name: Reload systemd after removing runner units
@ -326,26 +329,33 @@
mode: "0640"
loop: "{{ range(1, runner_count + 1) | list }}"
- name: Generate runner registration tokens
- name: Check if runners are already registered
ansible.builtin.stat:
path: "{{ runner_base_dir }}/runner-{{ item }}/.runner"
register: runner_registered
loop: "{{ range(1, runner_count + 1) | list }}"
- name: Generate runner registration tokens for unregistered runners
ansible.builtin.shell: |
sudo -u {{ gitea_user }} {{ gitea_bin }} --config /etc/gitea/app.ini actions generate-runner-token
register: runner_tokens
register: runner_tokens_raw
loop: "{{ range(1, runner_count + 1) | list }}"
when: not runner_registered.results[item - 1].stat.exists
- name: Register runners with generated tokens
become: true
become_user: "{{ gitea_user }}"
ansible.builtin.shell: |
cd {{ runner_base_dir }}/runner-{{ item.item }} && \
/usr/local/bin/act_runner register \
--instance {{ gitea_root_url }} \
timeout 30 /usr/local/bin/act_runner register \
--instance {{ gitea_runner_instance_url }} \
--token {{ item.stdout | trim }} \
--config {{ runner_base_dir }}/runner-{{ item.item }}/config.yaml \
--name runner-{{ item.item }} \
--no-interactive
loop: "{{ runner_tokens.results }}"
args:
creates: "{{ runner_base_dir }}/runner-{{ item.item }}/.runner"
loop: "{{ runner_tokens_raw.results | default([]) }}"
when: item is not skipped
ignore_errors: true
- name: Create runner systemd units
ansible.builtin.copy:
@ -384,14 +394,28 @@
state: restarted
loop: "{{ range(1, runner_count + 1) | list }}"
- name: Configure nginx reverse proxy (HTTP only for now)
- name: Configure nginx default site
ansible.builtin.copy:
dest: /etc/nginx/sites-available/default_block
mode: "0644"
content: |
# Default server block - reject requests without matching server_name
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444; # Close connection without response
}
- name: Configure nginx gitea site (HTTP only - initial)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/gitea
mode: "0644"
content: |
# Gitea server - HTTP only for initial setup
server {
listen 80;
server_name {{ ansible_host }};
listen [::]:80;
server_name git.nitrokite.com;
# Allow large artifact uploads
client_max_body_size 500M;
@ -404,26 +428,140 @@
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- name: Configure main nitrokite marketing site (HTTP only - initial)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/nitrokite-marketing
mode: "0644"
content: |
# Marketing site - HTTP only for initial setup
server {
listen 80;
listen [::]:80;
server_name nitrokite.com www.nitrokite.com;
- name: Enable nginx site
root /var/www/html;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
}
- name: Configure nginx API server site (HTTP only - initial)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/api-server
mode: "0644"
content: |
# API Server - HTTP only for initial setup
server {
listen 80;
listen [::]:80;
server_name api.nitrokite.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- name: Configure nginx app server site (HTTP only - initial)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/app
mode: "0644"
content: |
# App Server - HTTP only for initial setup
server {
listen 80;
listen [::]:80;
server_name app.nitrokite.com;
root /var/www/app;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
- name: Configure nginx grafana site (HTTP only - initial)
ansible.builtin.copy:
dest: /etc/nginx/sites-available/grafana
mode: "0644"
content: |
# Grafana - HTTP only for initial setup
server {
listen 80;
listen [::]:80;
server_name grafana.nitrokite.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- name: Enable nginx default block site
ansible.builtin.file:
src: /etc/nginx/sites-available/default_block
dest: /etc/nginx/sites-enabled/default_block
state: link
force: true
- name: Enable nginx nitrokite marketing site
ansible.builtin.file:
src: /etc/nginx/sites-available/nitrokite-marketing
dest: /etc/nginx/sites-enabled/nitrokite-marketing
state: link
force: true
- name: Enable nginx gitea site
ansible.builtin.file:
src: /etc/nginx/sites-available/gitea
dest: /etc/nginx/sites-enabled/gitea
state: link
force: true
- name: Enable nginx API server site
ansible.builtin.file:
src: /etc/nginx/sites-available/api-server
dest: /etc/nginx/sites-enabled/api-server
state: link
force: true
- name: Enable nginx app site
ansible.builtin.file:
src: /etc/nginx/sites-available/app
dest: /etc/nginx/sites-enabled/app
state: link
force: true
- name: Enable nginx grafana site
ansible.builtin.file:
src: /etc/nginx/sites-available/grafana
dest: /etc/nginx/sites-enabled/grafana
state: link
force: true
- name: Disable default nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
- name: Test and reload nginx
- name: Test nginx configuration
ansible.builtin.shell: |
nginx -t && systemctl reload nginx
nginx -t
args:
executable: /bin/bash
register: nginx_test
ignore_errors: true
- name: Allow firewall (SSH + HTTP)
- name: Reload nginx if config is valid
ansible.builtin.systemd:
name: nginx
state: reloaded
when: nginx_test.rc == 0
- name: Allow firewall (SSH + HTTP + HTTPS)
ansible.builtin.ufw:
rule: allow
name: "{{ item }}"
@ -441,6 +579,316 @@
state: enabled
policy: deny
# ==========================================
# SSL CERTIFICATE SETUP
# ==========================================
# Temporarily stop nginx to use standalone mode for certificate generation
- name: Stop nginx for SSL certificate generation
ansible.builtin.systemd:
name: nginx
state: stopped
- name: Obtain Let's Encrypt certificate for nitrokite.com
ansible.builtin.shell: |
certbot certonly --standalone --non-interactive --agree-tos \
--email faridonfire@gmail.com \
-d nitrokite.com
args:
creates: /etc/letsencrypt/live/nitrokite.com/fullchain.pem
ignore_errors: true
- name: Obtain Let's Encrypt certificate for git.nitrokite.com
ansible.builtin.shell: |
certbot certonly --standalone --non-interactive --agree-tos \
--email faridonfire@gmail.com \
-d git.nitrokite.com
args:
creates: /etc/letsencrypt/live/git.nitrokite.com/fullchain.pem
ignore_errors: true
- name: Obtain Let's Encrypt certificate for api.nitrokite.com
ansible.builtin.shell: |
certbot certonly --standalone --non-interactive --agree-tos \
--email faridonfire@gmail.com \
-d api.nitrokite.com
args:
creates: /etc/letsencrypt/live/api.nitrokite.com/fullchain.pem
ignore_errors: true
- name: Obtain Let's Encrypt certificate for app.nitrokite.com
ansible.builtin.shell: |
certbot certonly --standalone --non-interactive --agree-tos \
--email faridonfire@gmail.com \
-d app.nitrokite.com
args:
creates: /etc/letsencrypt/live/app.nitrokite.com/fullchain.pem
ignore_errors: true
- name: Obtain Let's Encrypt certificate for grafana.nitrokite.com
ansible.builtin.shell: |
certbot certonly --standalone --non-interactive --agree-tos \
--email faridonfire@gmail.com \
-d grafana.nitrokite.com
args:
creates: /etc/letsencrypt/live/grafana.nitrokite.com/fullchain.pem
ignore_errors: true
- name: Set up automatic certificate renewal
ansible.builtin.cron:
name: "Certbot automatic renewal"
minute: "0"
hour: "0,12"
job: "/usr/bin/certbot renew --quiet --post-hook 'systemctl reload nginx'"
# Now update nginx configs to use HTTPS with SSL certificates
- name: Update nginx default site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/default_block
mode: "0644"
content: |
# Default server block - reject requests without matching server_name
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
- name: Check if git.nitrokite.com certificate exists
ansible.builtin.stat:
path: /etc/letsencrypt/live/git.nitrokite.com/fullchain.pem
register: git_cert
- name: Update nginx gitea site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/gitea
mode: "0644"
content: |
server {
listen 80;
listen [::]:80;
server_name git.nitrokite.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name git.nitrokite.com;
ssl_certificate /etc/letsencrypt/live/git.nitrokite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.nitrokite.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 500M;
location / {
proxy_pass http://127.0.0.1:{{ gitea_http_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
when: git_cert.stat.exists
- name: Check if nitrokite.com certificate exists
ansible.builtin.stat:
path: /etc/letsencrypt/live/nitrokite.com/fullchain.pem
register: nitrokite_cert
- name: Update nginx marketing site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/nitrokite-marketing
mode: "0644"
content: |
server {
listen 80;
listen [::]:80;
server_name nitrokite.com www.nitrokite.com;
return 301 https://nitrokite.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name nitrokite.com www.nitrokite.com;
ssl_certificate /etc/letsencrypt/live/nitrokite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nitrokite.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/www/html;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
}
when: nitrokite_cert.stat.exists
- name: Check if api.nitrokite.com certificate exists
ansible.builtin.stat:
path: /etc/letsencrypt/live/api.nitrokite.com/fullchain.pem
register: api_cert
- name: Update nginx API server site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/api-server
mode: "0644"
content: |
server {
listen 80;
listen [::]:80;
server_name api.nitrokite.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.nitrokite.com;
ssl_certificate /etc/letsencrypt/live/api.nitrokite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.nitrokite.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
when: api_cert.stat.exists
- name: Check if app.nitrokite.com certificate exists
ansible.builtin.stat:
path: /etc/letsencrypt/live/app.nitrokite.com/fullchain.pem
register: app_cert
- name: Update nginx app site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/app
mode: "0644"
content: |
server {
listen 80;
listen [::]:80;
server_name app.nitrokite.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.nitrokite.com;
ssl_certificate /etc/letsencrypt/live/app.nitrokite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.nitrokite.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/www/app;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
when: app_cert.stat.exists
- name: Check if grafana.nitrokite.com certificate exists
ansible.builtin.stat:
path: /etc/letsencrypt/live/grafana.nitrokite.com/fullchain.pem
register: grafana_cert
- name: Update nginx grafana site with SSL
ansible.builtin.copy:
dest: /etc/nginx/sites-available/grafana
mode: "0644"
content: |
server {
listen 80;
listen [::]:80;
server_name grafana.nitrokite.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name grafana.nitrokite.com;
ssl_certificate /etc/letsencrypt/live/grafana.nitrokite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.nitrokite.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
when: grafana_cert.stat.exists
- name: Test nginx configuration with SSL
ansible.builtin.shell: |
nginx -t
args:
executable: /bin/bash
register: nginx_ssl_test
ignore_errors: true
- name: Start nginx with SSL configuration
ansible.builtin.systemd:
name: nginx
state: started
when: nginx_ssl_test.rc == 0
# ==========================================
# MARKETING WEBSITE DEPLOYMENT
# ==========================================
- name: Create /var/www/html directory
ansible.builtin.file:
path: /var/www/html
state: directory
mode: '0755'
owner: www-data
group: www-data
- name: Deploy marketing website files
ansible.builtin.synchronize:
src: ../../marketing/dist/
dest: /var/www/html/
delete: true
recursive: true
rsync_opts:
- "--chmod=D755,F644"
when: nitrokite_cert.stat.exists
- name: Set ownership of marketing website files
ansible.builtin.file:
path: /var/www/html
owner: www-data
group: www-data
recurse: true
when: nitrokite_cert.stat.exists
# ==========================================
# API SERVER DEPLOYMENT INFRASTRUCTURE
# ==========================================
@ -467,6 +915,22 @@
- /opt/api-artifacts/scripts
- /opt/api-artifacts/config
# ==========================================
# FRONTEND APP DEPLOYMENT INFRASTRUCTURE
# ==========================================
- name: Create frontend deployment directory structure
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
loop:
- /opt/frontend-artifacts
- /opt/frontend-artifacts/staging
- /opt/frontend-artifacts/backups
- /var/www/app
- name: Copy deployment scripts from local repository
ansible.builtin.copy:
src: "{{ playbook_dir }}/../scripts/{{ item }}"
@ -525,11 +989,11 @@
ansible.builtin.debug:
msg:
- "============================================"
- "Gitea + API Server Infrastructure Complete!"
- "Gitea + API Server + Frontend Infrastructure Complete!"
- "============================================"
- ""
- "Gitea:"
- " URL: {{ gitea_root_url }}"
- " URL: https://git.nitrokite.com"
- " Runners: {{ runner_count }} runners active"
- ""
- "API Server Deployment:"
@ -538,14 +1002,24 @@
- " Database user: {{ api_server_db_user }}"
- " Systemd service: api-server.service (enabled)"
- ""
- "Frontend App Deployment:"
- " Artifact directory: /opt/frontend-artifacts/"
- " Web directory: /var/www/app/"
- " URL: https://app.nitrokite.com"
- ""
- "Marketing Website:"
- " Web directory: /var/www/html/"
- " URL: https://nitrokite.com"
- ""
- "Next Steps:"
- "1. Create repository in Gitea and push your golang-api-server code"
- "1. Create repository in Gitea and push your code"
- "2. Push will trigger Gitea Actions build automatically"
- "3. After build completes, SSH to server and promote:"
- " sudo /opt/api-artifacts/scripts/promote.sh"
- " sudo /opt/api-artifacts/scripts/promote.sh <commit-sha>"
- ""
- "Management Commands:"
- " - View builds: ls /opt/api-artifacts/builds/"
- " - View frontend staging: ls /opt/frontend-artifacts/staging/"
- " - Check logs: journalctl -u api-server -f"
- " - Rollback: sudo /opt/api-artifacts/scripts/rollback.sh"

View File

@ -2,6 +2,8 @@
set -euo pipefail
ARTIFACT_DIR="/opt/api-artifacts"
FRONTEND_ARTIFACT_DIR="/opt/frontend-artifacts"
FRONTEND_WEB_DIR="/var/www/app"
CURRENT_LINK="$ARTIFACT_DIR/current"
BLUE_LINK="$ARTIFACT_DIR/blue"
GREEN_LINK="$ARTIFACT_DIR/green"
@ -16,6 +18,9 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Get commit SHA if provided
COMMIT_SHA="${1:-}"
if [ -L "$CURRENT_LINK" ]; then
CURRENT_TARGET=$(readlink "$CURRENT_LINK")
if [ "$CURRENT_TARGET" = "blue" ]; then
@ -37,7 +42,16 @@ fi
NEW_BUILD=$(readlink "$NEW_TARGET_LINK")
log_info "Promoting $NEW_TARGET to production"
log_info "New build: $NEW_BUILD"
log_info "New backend build: $NEW_BUILD"
# Check for frontend staging if commit SHA provided
if [ -n "$COMMIT_SHA" ] && [ -d "$FRONTEND_ARTIFACT_DIR/staging/$COMMIT_SHA" ]; then
log_info "Frontend build found: $COMMIT_SHA"
DEPLOY_FRONTEND=true
else
log_warn "No frontend build found for this commit"
DEPLOY_FRONTEND=false
fi
read -p "Continue with promotion? (yes/no): " -r
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
@ -48,6 +62,29 @@ fi
log_info "Switching current symlink to $NEW_TARGET"
ln -sfn "$NEW_TARGET" "$CURRENT_LINK"
# Deploy frontend if available
if [ "$DEPLOY_FRONTEND" = true ]; then
log_info "Deploying frontend to $FRONTEND_WEB_DIR..."
# Backup current frontend
if [ -d "$FRONTEND_WEB_DIR" ] && [ "$(ls -A $FRONTEND_WEB_DIR)" ]; then
BACKUP_DIR="$FRONTEND_ARTIFACT_DIR/backups/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp -r "$FRONTbackend deployment: $NEW_TARGET ($NEW_BUILD)"
if [ "$DEPLOY_FRONTEND" = true ]; then
log_info "Active frontend deployment: $COMMIT_SHA"
log_info "Frontend URL: https://app.nitrokite.com"
fi
log_info "Backed up current frontend to $BACKUP_DIR"
fi
# Deploy new frontend
rm -rf "$FRONTEND_WEB_DIR"/*
cp -r "$FRONTEND_ARTIFACT_DIR/staging/$COMMIT_SHA"/* "$FRONTEND_WEB_DIR/"
chown -R www-data:www-data "$FRONTEND_WEB_DIR"
log_info "Frontend deployed successfully"
fi
log_info "Reloading systemd service..."
systemctl reload-or-restart "$SERVICE_NAME"

View File

@ -0,0 +1,2 @@
# Vite environment variables
VITE_API_URL=http://localhost:8080

1
frontend/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=https://api.nitrokite.com

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

20
frontend/build.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
set -e
echo "🔨 Building Nitrokite Frontend..."
# Navigate to frontend directory
cd "$(dirname "$0")"
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
npm install
fi
# Build the application
echo "🏗️ Building production bundle..."
npm run build
echo "✅ Build complete! Output in ./dist/"
echo "📦 Ready to deploy"

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4983
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-form": "^1.27.6",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.143.6",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.4",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

64
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,64 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router';
import { DashboardLayout } from './layouts/DashboardLayout';
import { DashboardPage } from './pages/Dashboard';
import { OnboardingPage } from './pages/Onboarding';
import './index.css';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// Create root route
const rootRoute = createRootRoute();
// Create routes
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <OnboardingPage />,
});
const dashboardLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: DashboardLayout,
});
const dashboardRoute = createRoute({
getParentRoute: () => dashboardLayoutRoute,
path: '/',
component: DashboardPage,
});
// Create the route tree
const routeTree = rootRoute.addChildren([
indexRoute,
dashboardLayoutRoute.addChildren([dashboardRoute]),
]);
// Create the router
const router = createRouter({ routeTree });
// Register the router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App

View File

@ -0,0 +1,35 @@
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.nitrokite.com';
export const apiClient = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for adding auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for handling errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized access
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,51 @@
import * as React from "react"
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> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,78 @@
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 }

View File

@ -0,0 +1,24 @@
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 }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
)
Label.displayName = "Label"
export { Label }

View File

@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import type { DashboardStats } from '../types';
export const useDashboardStats = () => {
return useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: async () => {
const { data } = await apiClient.get<DashboardStats>('/dashboard/stats');
return data;
},
refetchInterval: 60000, // Refetch every minute
});
};
export const useOnboarding = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: any) => {
const response = await apiClient.post('/onboarding', data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['business'] });
},
});
};

View File

@ -0,0 +1,71 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import type { User, CreateUserInput, UpdateUserInput } from '../types';
// Fetch all users
export const useUsers = () => {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const { data } = await apiClient.get<User[]>('/users');
return data;
},
});
};
// Fetch single user
export const useUser = (id: number) => {
return useQuery({
queryKey: ['users', id],
queryFn: async () => {
const { data } = await apiClient.get<User>(`/users/${id}`);
return data;
},
enabled: !!id,
});
};
// Create user
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateUserInput) => {
const { data } = await apiClient.post<User>('/users', input);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
};
// Update user
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, input }: { id: number; input: UpdateUserInput }) => {
const { data } = await apiClient.put<User>(`/users/${id}`, input);
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
},
});
};
// Delete user
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/users/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
};

59
frontend/src/index.css Normal file
View File

@ -0,0 +1,59 @@
@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: 262 83% 58%;
--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: 262 83% 58%;
--radius: 0.5rem;
}
.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: 262 83% 58%;
--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: 262 83% 58%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,67 @@
import { Link, Outlet, useLocation } from '@tanstack/react-router';
import { LayoutDashboard, Globe, TrendingUp, Calendar, Star, MessageSquare, Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Website', href: '/dashboard/website', icon: Globe },
{ name: 'SEO', href: '/dashboard/seo', icon: TrendingUp },
{ name: 'Bookings', href: '/dashboard/bookings', icon: Calendar },
{ name: 'Reviews', href: '/dashboard/reviews', icon: Star },
{ name: 'Feedback', href: '/dashboard/feedback', icon: MessageSquare },
];
export function DashboardLayout() {
const location = useLocation();
return (
<div className="min-h-screen bg-background">
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-card border-r">
<div className="flex h-16 items-center px-6 border-b">
<span className="text-2xl">🪁</span>
<span className="ml-2 text-xl font-bold">Nitrokite</span>
</div>
<nav className="mt-6 px-3">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors mb-1",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
</div>
{/* Main content */}
<div className="pl-64">
{/* Header */}
<header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b bg-card px-6">
<button className="lg:hidden">
<Menu className="h-6 w-6" />
</button>
<div className="flex-1"></div>
<div className="flex items-center gap-4">
<div className="h-8 w-8 rounded-full bg-primary" />
</div>
</header>
{/* Page content */}
<main className="p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,120 @@
import { useDashboardStats } from '@/hooks/useDashboard';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TrendingUp, Users, Calendar, Star } from 'lucide-react';
export function DashboardPage() {
const { data: stats, isLoading } = useDashboardStats();
if (isLoading) {
return <div>Loading...</div>;
}
const statCards = [
{
title: 'Website Visits',
value: stats?.totalWebsiteVisits || 0,
icon: Users,
change: '+12.5%',
},
{
title: 'SEO Ranking',
value: stats?.seoRanking || 0,
icon: TrendingUp,
change: '+4.2%',
},
{
title: 'Bookings',
value: stats?.bookings || 0,
icon: Calendar,
change: '+8.1%',
},
{
title: 'Reviews',
value: stats?.reviews || 0,
icon: Star,
change: '+2.3%',
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back! Here's an overview of your business performance.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{stat.title}
</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-500">{stat.change}</span> from last month
</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<div className="flex-1">
<p className="text-sm font-medium">New booking received</p>
<p className="text-xs text-muted-foreground">2 hours ago</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
<div className="flex-1">
<p className="text-sm font-medium">Review submitted</p>
<p className="text-xs text-muted-foreground">4 hours ago</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-purple-500"></div>
<div className="flex-1">
<p className="text-sm font-medium">SEO ranking improved</p>
<p className="text-xs text-muted-foreground">1 day ago</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<button className="w-full rounded-lg border p-3 text-left text-sm hover:bg-accent">
Create new social media post
</button>
<button className="w-full rounded-lg border p-3 text-left text-sm hover:bg-accent">
Update website content
</button>
<button className="w-full rounded-lg border p-3 text-left text-sm hover:bg-accent">
View booking calendar
</button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useOnboarding } from '@/hooks/useDashboard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNavigate } from '@tanstack/react-router';
const onboardingSchema = z.object({
businessName: z.string().min(2, 'Business name must be at least 2 characters'),
industry: z.string().min(2, 'Industry is required'),
website: z.string().url().optional().or(z.literal('')),
});
type OnboardingFormData = z.infer<typeof onboardingSchema>;
export function OnboardingPage() {
const [step, setStep] = useState(1);
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const navigate = useNavigate();
const { mutate: submitOnboarding, isPending } = useOnboarding();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
});
const goals = [
'Increase website traffic',
'Improve SEO ranking',
'Generate more leads',
'Manage online reputation',
'Grow social media presence',
'Streamline bookings',
];
const services = [
'Website Creation',
'SEO Tracking',
'Online Booking',
'Social Media Content',
'Review Management',
'Customer Feedback',
];
const toggleSelection = (item: string, list: string[], setter: (val: string[]) => void) => {
if (list.includes(item)) {
setter(list.filter((i) => i !== item));
} else {
setter([...list, item]);
}
};
const onSubmit = (data: OnboardingFormData) => {
submitOnboarding(
{
...data,
goals: selectedGoals,
services: selectedServices,
},
{
onSuccess: () => {
navigate({ to: '/dashboard' });
},
}
);
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Welcome to Nitrokite! 🚀</CardTitle>
<CardDescription>
Let's get your business set up in just a few steps
</CardDescription>
<div className="mt-4 flex gap-2">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-2 flex-1 rounded-full ${
s <= step ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
</CardHeader>
<CardContent>
{step === 1 && (
<form onSubmit={handleSubmit(() => setStep(2))} className="space-y-4">
<div>
<Label htmlFor="businessName">Business Name</Label>
<Input
id="businessName"
{...register('businessName')}
placeholder="Enter your business name"
/>
{errors.businessName && (
<p className="text-sm text-destructive mt-1">
{errors.businessName.message}
</p>
)}
</div>
<div>
<Label htmlFor="industry">Industry</Label>
<Input
id="industry"
{...register('industry')}
placeholder="e.g., Restaurant, Retail, Services"
/>
{errors.industry && (
<p className="text-sm text-destructive mt-1">
{errors.industry.message}
</p>
)}
</div>
<div>
<Label htmlFor="website">Website (optional)</Label>
<Input
id="website"
{...register('website')}
placeholder="https://yourbusiness.com"
/>
{errors.website && (
<p className="text-sm text-destructive mt-1">
{errors.website.message}
</p>
)}
</div>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-3">What are your goals?</h3>
<div className="grid grid-cols-2 gap-2">
{goals.map((goal) => (
<button
key={goal}
type="button"
onClick={() => toggleSelection(goal, selectedGoals, setSelectedGoals)}
className={`p-3 rounded-lg border text-sm text-left transition-colors ${
selectedGoals.includes(goal)
? 'bg-primary text-primary-foreground border-primary'
: 'hover:bg-accent'
}`}
>
{goal}
</button>
))}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)} className="flex-1">
Back
</Button>
<Button onClick={() => setStep(3)} className="flex-1">
Continue
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-3">
Which services are you interested in?
</h3>
<div className="grid grid-cols-2 gap-2">
{services.map((service) => (
<button
key={service}
type="button"
onClick={() =>
toggleSelection(service, selectedServices, setSelectedServices)
}
className={`p-3 rounded-lg border text-sm text-left transition-colors ${
selectedServices.includes(service)
? 'bg-primary text-primary-foreground border-primary'
: 'hover:bg-accent'
}`}
>
{service}
</button>
))}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(2)} className="flex-1">
Back
</Button>
<Button
onClick={handleSubmit(onSubmit)}
disabled={isPending}
className="flex-1"
>
{isPending ? 'Setting up...' : 'Complete Setup'}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,43 @@
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface UpdateUserInput {
name?: string;
email?: string;
}
export interface Business {
id: number;
name: string;
industry: string;
website?: string;
userId: number;
createdAt: string;
updatedAt: string;
}
export interface OnboardingData {
businessName: string;
industry: string;
website?: string;
goals: string[];
services: string[];
}
export interface DashboardStats {
totalWebsiteVisits: number;
seoRanking: number;
socialMediaEngagement: number;
bookings: number;
reviews: number;
}

View File

@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
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: [],
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

24
marketing/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
marketing/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
marketing/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

43
marketing/README.md Normal file
View File

@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
site: 'https://nitrokite.com',
output: 'static',
build: {
inlineStylesheets: 'auto',
},
compressHTML: true,
});

19
marketing/build.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
set -e
echo "🚀 Building Nitrokite Marketing Site..."
# Navigate to marketing directory
cd "$(dirname "$0")"
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
npm install
fi
# Build the site
echo "🔨 Building static site..."
npm run build
echo "✅ Build complete! Output in ./dist/"

5070
marketing/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
marketing/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "marketing",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.16.6"
}
}

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -0,0 +1,162 @@
---
---
<section id="pricing" class="cta">
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Ready to Grow Your Business?</h2>
<p class="cta-subtitle">
Join hundreds of small businesses using Nitrokite to attract more customers and increase revenue.
</p>
<div class="cta-buttons">
<a href="https://app.nitrokite.com/signup" class="button button-primary">
Start Free Trial
</a>
<a href="#features" class="button button-secondary">
Learn More
</a>
</div>
<div class="cta-features">
<div class="cta-feature">
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Free 14-Day Trial</span>
</div>
<div class="cta-feature">
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>No Credit Card Required</span>
</div>
<div class="cta-feature">
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Cancel Anytime</span>
</div>
<div class="cta-feature">
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>24/7 Support</span>
</div>
</div>
</div>
</div>
</section>
<style>
.cta {
padding: var(--spacing-xl) 0;
background:
linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%),
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.1) 0%, transparent 70%);
}
.cta-content {
text-align: center;
max-width: 800px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: calc(var(--border-radius) * 2);
padding: var(--spacing-lg);
}
.cta-title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
margin-bottom: var(--spacing-sm);
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.cta-subtitle {
font-size: 1.2rem;
color: var(--color-text-muted);
margin-bottom: var(--spacing-lg);
line-height: 1.6;
}
.cta-buttons {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
flex-wrap: wrap;
margin-bottom: var(--spacing-lg);
}
.button {
padding: 1rem 2rem;
border-radius: var(--border-radius);
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.3s;
display: inline-block;
}
.button-primary {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.button-primary:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
}
.button-secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
border: 2px solid rgba(255, 255, 255, 0.1);
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.cta-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.cta-feature {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-muted);
font-size: 0.95rem;
}
.check-icon {
width: 20px;
height: 20px;
color: var(--color-accent);
flex-shrink: 0;
}
@media (max-width: 768px) {
.cta-buttons {
flex-direction: column;
align-items: stretch;
}
.button {
width: 100%;
}
.cta-features {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,126 @@
---
const features = [
{
icon: '🌐',
title: 'Website Creation',
description: 'Launch a professional website in minutes. No coding required. Mobile-responsive designs that convert visitors into customers.'
},
{
icon: '📈',
title: 'SEO Tracking',
description: 'Monitor your search rankings and get actionable insights. Track keywords, analyze competitors, and improve your visibility.'
},
{
icon: '📅',
title: 'Online Booking',
description: 'Let customers book appointments 24/7. Automated reminders, calendar sync, and no-show reduction tools included.'
},
{
icon: '🎬',
title: 'Social Media Content',
description: 'Generate engaging videos and posts for your social channels. AI-powered content creation with analytics tracking.'
},
{
icon: '⭐',
title: 'Review Management',
description: 'Monitor and respond to reviews on Google Maps, Yelp, and more. Build trust and manage your online reputation.'
},
{
icon: '💬',
title: 'Customer Feedback',
description: 'Collect valuable insights from your customers. Survey tools and feedback forms to improve your services.'
}
];
---
<section id="features" class="features">
<div class="container">
<div class="section-header">
<h2 class="section-title">Everything You Need</h2>
<p class="section-subtitle">
A complete platform for modern API development
</p>
</div>
<div class="features-grid">
{features.map((feature) => (
<div class="feature-card">
<div class="feature-icon">{feature.icon}</div>
<h3 class="feature-title">{feature.title}</h3>
<p class="feature-description">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.features {
padding: var(--spacing-xl) 0;
background: linear-gradient(180deg, transparent 0%, rgba(99, 102, 241, 0.03) 50%, transparent 100%);
}
.section-header {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.section-title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
margin-bottom: var(--spacing-sm);
background: linear-gradient(135deg, var(--color-text), var(--color-text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.section-subtitle {
font-size: 1.2rem;
color: var(--color-text-muted);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.feature-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--border-radius);
padding: var(--spacing-md);
transition: all 0.3s;
}
.feature-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(99, 102, 241, 0.3);
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.2);
}
.feature-icon {
font-size: 3rem;
margin-bottom: var(--spacing-sm);
}
.feature-title {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: var(--spacing-xs);
color: var(--color-text);
}
.feature-description {
color: var(--color-text-muted);
line-height: 1.6;
}
@media (max-width: 768px) {
.features-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,161 @@
---
---
<section class="hero">
<div class="container">
<div class="hero-content">
<h1 class="hero-title">
Grow Your Business with
<span class="gradient-text">Digital Marketing</span>
</h1>
<p class="hero-subtitle">
Nitrokite helps small businesses thrive online with website creation, SEO tracking,
online booking, social media content generation, and reputation management - all in one place.
</p>
<div class="hero-cta">
<a href="https://app.nitrokite.com/signup" class="button button-primary">
Start Free Trial
</a>
<a href="#features" class="button button-secondary">
Learn More
</a>
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-number">All-in-One</span>
<span class="stat-label">Marketing Platform</span>
</div>
<div class="stat">
<span class="stat-number">24/7</span>
<span class="stat-label">Customer Reach</span>
</div>
<div class="stat">
<span class="stat-number">Easy</span>
<span class="stat-label">Setup & Use</span>
</div>
</div>
</div>
</div>
</section>
<style>
.hero {
padding: var(--spacing-xl) 0;
background:
radial-gradient(circle at 20% 50%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(139, 92, 246, 0.15) 0%, transparent 50%);
}
.hero-content {
text-align: center;
max-width: 900px;
margin: 0 auto;
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: var(--spacing-md);
letter-spacing: -0.02em;
}
.gradient-text {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary), var(--color-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: clamp(1.1rem, 2vw, 1.3rem);
color: var(--color-text-muted);
margin-bottom: var(--spacing-lg);
line-height: 1.6;
}
.hero-cta {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
flex-wrap: wrap;
margin-bottom: var(--spacing-lg);
}
.button {
padding: 1rem 2rem;
border-radius: var(--border-radius);
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.3s;
display: inline-block;
}
.button-primary {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.button-primary:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
}
.button-secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
border: 2px solid rgba(255, 255, 255, 0.1);
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.hero-stats {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
margin-top: var(--spacing-xl);
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
color: var(--color-text-muted);
font-size: 0.9rem;
font-weight: 500;
}
@media (max-width: 768px) {
.hero {
padding: var(--spacing-lg) 0;
}
.hero-cta {
flex-direction: column;
align-items: stretch;
}
.button {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,176 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Nitrokite - Complete digital marketing platform for small businesses. Website creation, SEO, booking, social media, and review management." />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<nav class="nav">
<div class="container">
<div class="nav-content">
<a href="/" class="logo">
<span class="logo-icon">🪁</span>
<span class="logo-text">Nitrokite</span>
</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
<a href="https://app.nitrokite.com">Login</a>
<a href="https://app.nitrokite.com/signup" class="cta-button">Get Started</a>
</div>
</div>
</div>
</nav>
<main>
<slot />
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2025 Nitrokite. All rights reserved.</p>
</div>
</footer>
</body>
</html>
<style is:global>
:root {
--color-primary: #6366f1;
--color-primary-dark: #4f46e5;
--color-secondary: #8b5cf6;
--color-accent: #06b6d4;
--color-bg: #0f172a;
--color-bg-light: #1e293b;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 4rem;
--spacing-xl: 6rem;
--border-radius: 0.5rem;
--max-width: 1200px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--spacing-md);
}
/* Navigation */
.nav {
position: sticky;
top: 0;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
z-index: 100;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) 0;
}
.logo {
display: flex;
align-items: center;
gap: var(--spacing-xs);
text-decoration: none;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
}
.logo-icon {
font-size: 2rem;
}
.nav-links {
display: flex;
gap: var(--spacing-md);
align-items: center;
}
.nav-links a {
color: var(--color-text-muted);
text-decoration: none;
transition: color 0.2s;
font-weight: 500;
}
.nav-links a:hover {
color: var(--color-text);
}
.cta-button {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white !important;
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--border-radius);
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(99, 102, 241, 0.3);
}
/* Footer */
.footer {
background: var(--color-bg-light);
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: var(--spacing-md) 0;
text-align: center;
color: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.nav-links {
gap: var(--spacing-sm);
}
.logo-text {
display: none;
}
}
</style>

View File

@ -0,0 +1,12 @@
---
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero.astro';
import Features from '../components/Features.astro';
import CTA from '../components/CTA.astro';
---
<Layout title="Nitrokite - Digital Marketing for Small Business">
<Hero />
<Features />
<CTA />
</Layout>

5
marketing/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}