Add frontend React dashboard with CI/CD deployment pipeline
This commit is contained in:
parent
f6833616ff
commit
a1360919f0
@ -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 }}"
|
||||
|
||||
190
.gitea/workflows/build-deploy.yml.bak
Normal file
190
.gitea/workflows/build-deploy.yml.bak
Normal 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
2
.gitignore
vendored
@ -9,6 +9,8 @@ tmp/
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
api-server
|
||||
backend/api-server
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
1
PROJECT_DESCRIPTION.md
Normal file
1
PROJECT_DESCRIPTION.md
Normal file
@ -0,0 +1 @@
|
||||
NitroKite is a project that will allow customers to fully manage their
|
||||
112
README.md
Normal file
112
README.md
Normal 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
108
backend/README.md
Normal 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
23
backend/build.sh
Executable 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}"
|
||||
@ -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
|
||||
@ -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=
|
||||
@ -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
|
||||
|
||||
@ -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,46 +394,174 @@
|
||||
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: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ ansible_host }};
|
||||
|
||||
# Allow large artifact uploads
|
||||
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;
|
||||
}
|
||||
}
|
||||
# Gitea server - HTTP only for initial setup
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name git.nitrokite.com;
|
||||
|
||||
# Allow large artifact uploads
|
||||
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;
|
||||
}
|
||||
}
|
||||
- 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;
|
||||
|
||||
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: Enable nginx site
|
||||
- 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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
# Vite environment variables
|
||||
VITE_API_URL=http://localhost:8080
|
||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL=https://api.nitrokite.com
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
20
frontend/build.sh
Executable 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4983
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
64
frontend/src/App.tsx
Normal 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
|
||||
35
frontend/src/api/client.ts
Normal file
35
frontend/src/api/client.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
51
frontend/src/components/ui/button.tsx
Normal file
51
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
78
frontend/src/components/ui/card.tsx
Normal file
78
frontend/src/components/ui/card.tsx
Normal 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 }
|
||||
24
frontend/src/components/ui/input.tsx
Normal file
24
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
21
frontend/src/components/ui/label.tsx
Normal file
21
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
29
frontend/src/hooks/useDashboard.ts
Normal file
29
frontend/src/hooks/useDashboard.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
71
frontend/src/hooks/useUsers.ts
Normal file
71
frontend/src/hooks/useUsers.ts
Normal 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
59
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
67
frontend/src/layouts/DashboardLayout.tsx
Normal file
67
frontend/src/layouts/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
120
frontend/src/pages/Dashboard.tsx
Normal file
120
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
frontend/src/pages/Onboarding.tsx
Normal file
218
frontend/src/pages/Onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/types/index.ts
Normal file
43
frontend/src/types/index.ts
Normal 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;
|
||||
}
|
||||
74
frontend/tailwind.config.js
Normal file
74
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
13
frontend/vite.config.ts
Normal 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
24
marketing/.gitignore
vendored
Normal 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
4
marketing/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
marketing/.vscode/launch.json
vendored
Normal file
11
marketing/.vscode/launch.json
vendored
Normal 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
43
marketing/README.md
Normal 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).
|
||||
12
marketing/astro.config.mjs
Normal file
12
marketing/astro.config.mjs
Normal 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
19
marketing/build.sh
Executable 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
5070
marketing/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
marketing/package.json
Normal file
14
marketing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
marketing/public/favicon.svg
Normal file
9
marketing/public/favicon.svg
Normal 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 |
162
marketing/src/components/CTA.astro
Normal file
162
marketing/src/components/CTA.astro
Normal 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>
|
||||
126
marketing/src/components/Features.astro
Normal file
126
marketing/src/components/Features.astro
Normal 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>
|
||||
161
marketing/src/components/Hero.astro
Normal file
161
marketing/src/components/Hero.astro
Normal 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>
|
||||
176
marketing/src/layouts/Layout.astro
Normal file
176
marketing/src/layouts/Layout.astro
Normal 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>© 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>
|
||||
12
marketing/src/pages/index.astro
Normal file
12
marketing/src/pages/index.astro
Normal 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
5
marketing/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user