clear old assets, new ci/cd flow
86
.env.example
@@ -1,67 +1,59 @@
|
||||
DATABASE_URL="postgresql://kordant:kordant_dev@localhost:5432/kordant"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
# Database (Turso / libSQL)
|
||||
DATABASE_URL="libsql://your-db.turso.io"
|
||||
DATABASE_AUTH_TOKEN=""
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
HIBP_API_KEY=""
|
||||
NODE_ENV="development"
|
||||
LOG_LEVEL="info"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=""
|
||||
SESSION_SECRET=""
|
||||
|
||||
# Payments (Stripe)
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
STRIPE_PRICE_PLUS_MONTHLY=""
|
||||
STRIPE_PRICE_PREMIUM_MONTHLY=""
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=""
|
||||
AWS_REGION="us-east-1"
|
||||
|
||||
# Datadog APM Configuration
|
||||
DD_SERVICE="kordant-api"
|
||||
DD_ENV="development"
|
||||
DD_VERSION="0.1.0"
|
||||
DD_TRACE_ENABLED="true"
|
||||
DD_TRACE_SAMPLE_RATE="1.0"
|
||||
DD_LOGS_INJECTION="true"
|
||||
DD_AGENT_HOST="localhost"
|
||||
DD_AGENT_PORT="8126"
|
||||
DD_API_KEY=""
|
||||
DD_SITE="datadoghq.com"
|
||||
|
||||
# Sentry Error Tracking
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
SENTRY_RELEASE="0.1.0"
|
||||
SENTRY_TRACES_SAMPLE_RATE="0.1"
|
||||
|
||||
# Google Analytics 4
|
||||
GA4_MEASUREMENT_ID=""
|
||||
GA4_API_SECRET=""
|
||||
|
||||
# Mixpanel Product Analytics
|
||||
MIXPANEL_TOKEN=""
|
||||
MIXPANEL_API_SECRET=""
|
||||
ANALYTICS_ENV="development"
|
||||
|
||||
# ============================================
|
||||
# Push Notifications Configuration
|
||||
# ============================================
|
||||
|
||||
# Firebase Cloud Messaging (FCM) - Android
|
||||
# Push Notifications
|
||||
FCM_PROJECT_ID=""
|
||||
FCM_CLIENT_EMAIL=""
|
||||
FCM_PRIVATE_KEY=""
|
||||
|
||||
# Apple Push Notification Service (APNs) - iOS
|
||||
APNS_KEY_ID=""
|
||||
APNS_TEAM_ID=""
|
||||
APNS_BUNDLE_ID=""
|
||||
APNS_KEY=""
|
||||
|
||||
# Twilio - SMS (optional)
|
||||
# SMS (Twilio)
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_MESSAGING_SERVICE_SID=""
|
||||
|
||||
# External APIs
|
||||
HIBP_API_KEY=""
|
||||
SECURITYTRAILS_API_KEY=""
|
||||
CENSYS_API_ID=""
|
||||
CENSYS_API_SECRET=""
|
||||
SHODAN_API_KEY=""
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=""
|
||||
SENTRY_ENVIRONMENT="development"
|
||||
DD_API_KEY=""
|
||||
DD_SITE="datadoghq.com"
|
||||
|
||||
# Analytics
|
||||
MIXPANEL_TOKEN=""
|
||||
GA4_MEASUREMENT_ID=""
|
||||
|
||||
# Notification Rate Limits
|
||||
PUSH_RATE_LIMIT=100
|
||||
EMAIL_RATE_LIMIT=60
|
||||
SMS_RATE_LIMIT=30
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# Frontend Environment Variables (Vite)
|
||||
# Add these to packages/web/.env or your frontend .env files:
|
||||
# VITE_MIXPANEL_TOKEN=<same-as-backend-token>
|
||||
# VITE_GA_MEASUREMENT_ID=<same-as-backend-id>
|
||||
# VITE_META_PIXEL_ID=""
|
||||
# VITE_LINKEDIN_PARTNER_ID=""
|
||||
|
||||
246
.github/workflows/ci.yml
vendored
@@ -1,246 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
PNPM_VERSION: "9"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build all packages
|
||||
run: pnpm build
|
||||
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: kordant
|
||||
POSTGRES_USER: kordant
|
||||
POSTGRES_PASSWORD: kordant_dev
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U kordant"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
env:
|
||||
DATABASE_URL: "postgresql://kordant:kordant_dev@localhost:5432/kordant"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: kordant-coverage
|
||||
fail_on_empty: false
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: kordant:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
security-scan:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run pnpm audit
|
||||
run: pnpm audit --prod
|
||||
- name: Trivy filesystem scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: "."
|
||||
format: table
|
||||
exit-code: 1
|
||||
ignore-unfixed: true
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
terraform-plan:
|
||||
name: Terraform Plan
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
- name: Terraform Format
|
||||
working-directory: infra
|
||||
run: terraform fmt -check -diff
|
||||
- name: Terraform Init
|
||||
working-directory: infra
|
||||
run: terraform init
|
||||
- name: Terraform Validate
|
||||
working-directory: infra
|
||||
run: terraform validate
|
||||
- name: Terraform Plan
|
||||
working-directory: infra
|
||||
run: terraform plan -var-file=environments/staging/terraform.tfvars.example -no-color
|
||||
env:
|
||||
TF_VAR_hibp_api_key: ${{ secrets.HIBP_API_KEY }}
|
||||
TF_VAR_resend_api_key: ${{ secrets.RESEND_API_KEY }}
|
||||
|
||||
load-test:
|
||||
name: Load Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test, docker-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install k6
|
||||
run: |
|
||||
K6_VERSION="v0.50.0"
|
||||
K6_URL="https://github.com/grafana/k6/releases/download/${K6_VERSION}/k6-linux-amd64.tar.gz"
|
||||
K6_SHA256="d950a2408d0be2dc81aef397a7c984a1d84271d7ae94ff7a47d08371904f0800"
|
||||
curl -sSL "${K6_URL}" -o k6.tar.gz
|
||||
echo "${K6_SHA256} k6.tar.gz" | sha256sum --check --strict -
|
||||
tar xzf k6.tar.gz
|
||||
sudo mv k6 /usr/local/bin/
|
||||
k6 version
|
||||
|
||||
- name: Validate required secrets
|
||||
run: |
|
||||
if [ -z "$API_TOKEN" ]; then
|
||||
echo "❌ LOAD_TEST_API_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run combined load tests
|
||||
run: |
|
||||
chmod +x scripts/load-test/run-all.sh
|
||||
./scripts/load-test/run-all.sh
|
||||
env:
|
||||
LOAD_TEST_BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL || 'http://localhost:3000' }}
|
||||
API_TOKEN: ${{ secrets.LOAD_TEST_API_TOKEN }}
|
||||
TARGET_RPS: ${{ vars.LOAD_TEST_TARGET_RPS || '500' }}
|
||||
DURATION: ${{ vars.LOAD_TEST_DURATION || '300s' }}
|
||||
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN || '' }}
|
||||
K6_CLOUD_PROJECT_ID: ${{ vars.K6_CLOUD_PROJECT_ID || '' }}
|
||||
|
||||
- name: Upload load test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: load-test-report-${{ github.sha }}
|
||||
path: scripts/load-test/reports/
|
||||
retention-days: 30
|
||||
|
||||
- name: Check P99 thresholds
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f scripts/load-test/reports/threshold-results.json ]; then
|
||||
FAILURES=$(jq -r '[.services | to_entries[] | select(.value.exitCode != 0) | .key] | join(", ")' scripts/load-test/reports/threshold-results.json 2>/dev/null || echo "")
|
||||
if [ -n "$FAILURES" ] && [ "$FAILURES" != "" ]; then
|
||||
echo "❌ Load test failures: $FAILURES"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All load tests passed"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No threshold results file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate auto-scaling
|
||||
if: always()
|
||||
run: |
|
||||
SUMMARY_FILE=$(ls scripts/load-test/reports/*-summary-*.json 2>/dev/null | head -1)
|
||||
if [ -n "$SUMMARY_FILE" ]; then
|
||||
MAX_VUS=$(jq -r '.metrics.vus.max // 0' "$SUMMARY_FILE")
|
||||
TARGET_VUS=20
|
||||
if [ "$(echo "$MAX_VUS >= $TARGET_VUS" | bc -l)" -eq 1 ]; then
|
||||
echo "✅ Auto-scaling validated: max VUs ($MAX_VUS) >= target ($TARGET_VUS)"
|
||||
else
|
||||
echo "⚠️ Auto-scaling below target: max VUs ($MAX_VUS) < target ($TARGET_VUS)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No summary file for auto-scaling validation"
|
||||
fi
|
||||
242
.github/workflows/deploy.yml
vendored
@@ -1,242 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
PNPM_VERSION: "9"
|
||||
|
||||
jobs:
|
||||
detect-environment:
|
||||
name: Detect Environment
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
environment: ${{ steps.detect.outputs.environment }}
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Detect deployment target
|
||||
id: detect
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "environment=production" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Calculate tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ steps.detect.outputs.environment }}" = "production" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
terraform-apply:
|
||||
name: Terraform Apply
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-environment
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: "~> 1.5"
|
||||
- name: Terraform Init
|
||||
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||
run: terraform init -backend-config="bucket=kordant-${{ needs.detect-environment.outputs.environment }}-terraform-state"
|
||||
- name: Terraform Plan
|
||||
id: plan
|
||||
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||
run: |
|
||||
terraform plan \
|
||||
-var="hibp_api_key=${{ secrets.HIBP_API_KEY }}" \
|
||||
-var="resend_api_key=${{ secrets.RESEND_API_KEY }}" \
|
||||
-var="sentry_dsn=${{ secrets.SENTRY_DSN }}" \
|
||||
-var="datadog_api_key=${{ secrets.DATADOG_API_KEY }}" \
|
||||
-no-color | tee /tmp/terraform-plan.out
|
||||
- name: Terraform Apply
|
||||
working-directory: infra/environments/${{ needs.detect-environment.outputs.environment }}
|
||||
run: |
|
||||
terraform apply -auto-approve \
|
||||
-var="hibp_api_key=${{ secrets.HIBP_API_KEY }}" \
|
||||
-var="resend_api_key=${{ secrets.RESEND_API_KEY }}" \
|
||||
-var="sentry_dsn=${{ secrets.SENTRY_DSN }}" \
|
||||
-var="datadog_api_key=${{ secrets.DATADOG_API_KEY }}"
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
|
||||
build-and-push:
|
||||
name: Build and Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-environment]
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: api
|
||||
dockerfile: packages/api/Dockerfile
|
||||
- name: darkwatch
|
||||
dockerfile: services/darkwatch/Dockerfile
|
||||
- name: spamshield
|
||||
dockerfile: services/spamshield/Dockerfile
|
||||
- name: voiceprint
|
||||
dockerfile: services/voiceprint/Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Calculate image tag
|
||||
id: tag
|
||||
run: echo "tag=${{ needs.detect-environment.outputs.tag }}" >> $GITHUB_OUTPUT
|
||||
- name: Build and push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/kordant-${{ matrix.name }}:${{ steps.tag.outputs.tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/kordant-${{ matrix.name }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy-ecs:
|
||||
name: Deploy to ECS
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-environment, terraform-apply, build-and-push]
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [api, darkwatch, spamshield, voiceprint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure AWS
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
- name: Update ECS Service
|
||||
run: |
|
||||
IMAGE="ghcr.io/${{ github.repository_owner }}/kordant-${{ matrix.service }}:${{ needs.detect-environment.outputs.tag }}"
|
||||
CLUSTER="kordant-${{ needs.detect-environment.outputs.environment }}"
|
||||
SERVICE="${{ matrix.service }}"
|
||||
|
||||
TASK_DEF=$(aws ecs describe-task-definition \
|
||||
--task-definition "${CLUSTER}-${SERVICE}" \
|
||||
--query 'taskDefinition' --output json)
|
||||
|
||||
NEW_TASK_DEF=$(echo "$TASK_DEF" | jq \
|
||||
--arg image "$IMAGE" \
|
||||
'.containerDefinitions[0].image = $image')
|
||||
|
||||
NEW_TASK_DEF_ARN=$(echo "$NEW_TASK_DEF" | \
|
||||
aws ecs register-task-definition \
|
||||
--family "${CLUSTER}-${SERVICE}" \
|
||||
--cli-input-json - \
|
||||
--query 'taskDefinition.taskDefinitionArn' --output text)
|
||||
|
||||
aws ecs update-service \
|
||||
--cluster "$CLUSTER" \
|
||||
--service "${CLUSTER}-${SERVICE}" \
|
||||
--task-definition "$NEW_TASK_DEF_ARN" \
|
||||
--force-new-deployment
|
||||
|
||||
echo "Deployed $IMAGE to $SERVICE"
|
||||
|
||||
health-check:
|
||||
name: Post-Deploy Health Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-environment, deploy-ecs]
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
steps:
|
||||
- name: Configure AWS
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
- name: Wait for deployment
|
||||
run: sleep 30
|
||||
- name: Health Check
|
||||
id: health
|
||||
run: |
|
||||
ENV="${{ needs.detect-environment.outputs.environment }}"
|
||||
CLUSTER="kordant-${ENV}"
|
||||
|
||||
ALB_DNS=$(aws elbv2 describe-load-balancers \
|
||||
--query "LoadBalancers[?contains(LoadBalancerName, '${CLUSTER}-alb')].DNSName" \
|
||||
--output text)
|
||||
|
||||
if [ -z "$ALB_DNS" ]; then
|
||||
echo "Health check failed: ALB DNS not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ALB DNS: $ALB_DNS"
|
||||
|
||||
FAILED=0
|
||||
for service in api darkwatch spamshield voiceprint; do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"https://${ALB_DNS}/health" || true)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Health check passed: $service"
|
||||
else
|
||||
echo "Health check failed: $service (HTTP $HTTP_CODE)"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rollback:
|
||||
name: Rollback on Failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-environment, deploy-ecs, health-check]
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
if: failure() && needs.health-check.result == 'failure'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [api, darkwatch, spamshield, voiceprint]
|
||||
steps:
|
||||
- name: Configure AWS
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
- name: Rollback ECS Service
|
||||
run: |
|
||||
CLUSTER="kordant-${{ needs.detect-environment.outputs.environment }}"
|
||||
SERVICE="${{ matrix.service }}"
|
||||
|
||||
aws ecs update-service \
|
||||
--cluster "$CLUSTER" \
|
||||
--service "${CLUSTER}-${SERVICE}" \
|
||||
--rollback \
|
||||
--no-cli-auto-prompt
|
||||
|
||||
echo "Rolled back $SERVICE"
|
||||
105
.github/workflows/load-test.yml
vendored
@@ -1,105 +0,0 @@
|
||||
name: Load Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_rps:
|
||||
description: 'Target requests per second'
|
||||
required: false
|
||||
default: '500'
|
||||
duration:
|
||||
description: 'Test duration'
|
||||
required: false
|
||||
default: '300s'
|
||||
service:
|
||||
description: 'Service to test (all, api, darkwatch, spamshield, voiceprint)'
|
||||
required: false
|
||||
default: 'all'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
|
||||
jobs:
|
||||
load-test:
|
||||
name: Load Test (${{ github.event.inputs.service || 'all' }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install k6
|
||||
run: |
|
||||
K6_VERSION="v0.50.0"
|
||||
K6_URL="https://github.com/grafana/k6/releases/download/${K6_VERSION}/k6-linux-amd64.tar.gz"
|
||||
K6_SHA256="d950a2408d0be2dc81aef397a7c984a1d84271d7ae94ff7a47d08371904f0800"
|
||||
curl -sSL "${K6_URL}" -o k6.tar.gz
|
||||
echo "${K6_SHA256} k6.tar.gz" | sha256sum --check --strict -
|
||||
tar xzf k6.tar.gz
|
||||
sudo mv k6 /usr/local/bin/
|
||||
k6 version
|
||||
|
||||
- name: Validate required secrets
|
||||
run: |
|
||||
if [ -z "$API_TOKEN" ]; then
|
||||
echo "❌ LOAD_TEST_API_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run load tests
|
||||
run: |
|
||||
chmod +x scripts/load-test/run-all.sh
|
||||
./scripts/load-test/run-all.sh ${{ github.event.inputs.service || 'all' }}
|
||||
env:
|
||||
LOAD_TEST_BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL || 'http://localhost:3000' }}
|
||||
API_TOKEN: ${{ secrets.LOAD_TEST_API_TOKEN }}
|
||||
TARGET_RPS: ${{ github.event.inputs.target_rps || '500' }}
|
||||
DURATION: ${{ github.event.inputs.duration || '300s' }}
|
||||
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN || '' }}
|
||||
K6_CLOUD_PROJECT_ID: ${{ vars.K6_CLOUD_PROJECT_ID || '' }}
|
||||
|
||||
- name: Upload load test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: load-test-report-${{ github.sha }}
|
||||
path: scripts/load-test/reports/
|
||||
retention-days: 30
|
||||
|
||||
- name: Check P99 thresholds
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f scripts/load-test/reports/threshold-results.json ]; then
|
||||
FAILURES=$(jq -r '[.services | to_entries[] | select(.value.exitCode != 0) | .key] | join(", ")' scripts/load-test/reports/threshold-results.json 2>/dev/null || echo "")
|
||||
if [ -n "$FAILURES" ] && [ "$FAILURES" != "" ]; then
|
||||
echo "❌ Load test failures: $FAILURES"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All load tests passed"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No threshold results file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate auto-scaling
|
||||
if: always()
|
||||
run: |
|
||||
SUMMARY_FILE=$(ls scripts/load-test/reports/*-summary-*.json 2>/dev/null | head -1)
|
||||
if [ -n "$SUMMARY_FILE" ]; then
|
||||
MAX_VUS=$(jq -r '.metrics.vus.max // 0' "$SUMMARY_FILE")
|
||||
TARGET_VUS=20
|
||||
if [ "$(echo "$MAX_VUS >= $TARGET_VUS" | bc -l)" -eq 1 ]; then
|
||||
echo "✅ Auto-scaling validated: max VUs ($MAX_VUS) >= target ($TARGET_VUS)"
|
||||
else
|
||||
echo "⚠️ Auto-scaling below target: max VUs ($MAX_VUS) < target ($TARGET_VUS)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No summary file for auto-scaling validation"
|
||||
fi
|
||||
15
.gitignore
vendored
@@ -2,8 +2,23 @@ node_modules
|
||||
dist
|
||||
.output
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.turbo
|
||||
.nitro
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
# Mobile build artifacts
|
||||
iOS/Kordant/build
|
||||
android/.gradle
|
||||
android/build
|
||||
android/app/build
|
||||
*.keystore
|
||||
*.jks
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
348
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**Multi-layered consumer identity protection against predatory AI-driven scams.**
|
||||
|
||||
Kordant combines three detection engines — voice cloning detection, dark web monitoring, and real-time spam classification — to give consumers proactive defense against modern identity fraud.
|
||||
Kordant combines five service domains — voice cloning detection, dark web monitoring, spam classification, property monitoring, and data broker removal — into a unified platform with web, iOS, and Android apps.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,97 +15,74 @@ Kordant flips the model. We detect the scam _as it happens_:
|
||||
- **VoicePrint** analyzes inbound calls in real time to flag synthetic AI-generated voices before you're socially engineered.
|
||||
- **DarkWatch** continuously monitors dark web forums, breach databases, and data broker caches — notifying you the moment your credentials, phone, or SSN surface.
|
||||
- **SpamShield** intercepts and classifies spam calls and SMS at the network level, blocking threats before they reach your phone.
|
||||
|
||||
Backed by ML models (ECAPA-TDNN, BERT) and a real-time alert pipeline, Kordant gives consumers enterprise-grade threat detection for their personal life.
|
||||
- **HomeTitle** monitors county deed records for unauthorized ownership changes, liens, and fraud.
|
||||
- **RemoveBrokers** automates data broker opt-out requests to remove your personal info from people-search sites.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
## Architecture
|
||||
|
||||
Unified SolidStart monolith with tRPC, Drizzle ORM, and native mobile apps.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
│ Mobile (Expo/RN) │ Web (SolidJS) │ Browser Extension │
|
||||
└──────────┬──────────────────────────────┬───────────────┘
|
||||
│ REST + WebSocket │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────────┐
|
||||
│ API Gateway │ │ WebSocket Alert Server │
|
||||
│ (Fastify 5) │ │ (Real-time push) │
|
||||
└──────┬───────┬───────┘ └──────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌─────────────────────────────────────────────┐
|
||||
│ Auth │ │ Microservices │
|
||||
│(NextAuth)│ │ VoicePrint │ DarkWatch │ SpamShield │
|
||||
└──────────┘ │ HomeTitle │ RemoveBrokers │
|
||||
└──────────┬──────────┬───────────────────────┘
|
||||
│ │
|
||||
┌────────▼──────────▼────────┐
|
||||
│ Background Workers │
|
||||
│ (BullMQ + Redis) │
|
||||
└────────┬───────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ PostgreSQL │
|
||||
│ + Redis │
|
||||
└─────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
│ Web (SolidStart) │ iOS (SwiftUI) │ Android (Compose) │ Ext │
|
||||
└────────────────────┬─────────────────────────────────────────┘
|
||||
│ tRPC (HTTP/WS)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ web/ (SolidStart) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Frontend (SolidStart + Tailwind) │ │
|
||||
│ │ Landing │ Auth │ Dashboard │ Service Pages │ │
|
||||
│ └─────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Backend (tRPC routers) │ │
|
||||
│ │ auth │ user │ family │ billing │ darkwatch │ │ │
|
||||
│ │ voiceprint │ spamshield │ hometitle │ removebrokers │ │ │
|
||||
│ │ alerts │ reports │ notifications │ correlation │ │
|
||||
│ └─────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Background Jobs (scheduler + workers) │ │
|
||||
│ └─────────────────────┬───────────────────────────────────┘ │
|
||||
└────────────────────────┼──────────────────────────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ PostgreSQL │
|
||||
│ + Redis │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features & Implementation Status
|
||||
## Directory Structure
|
||||
|
||||
| Feature | Service |Status | Notes |
|
||||
|---------|---------|--------|-------|
|
||||
| Voice enrollment & profile management | VoicePrint | ✅ Done | Register family voice profiles |
|
||||
| Audio preprocessing (VAD, noise reduction) | VoicePrint | ✅ Done | WebRTC VAD + RNNoise |
|
||||
| Synthetic voice detection (ECAPA-TDNN) | VoicePrint | ✅ Done | FAISS vector index for matching |
|
||||
| Real-time streaming audio analysis | VoicePrint | ✅ Done | WebSocket-based |
|
||||
| Batch audio analysis | VoicePrint | ✅ Done | Configurable confidence thresholds per tier |
|
||||
| HIBP breach checking | DarkWatch | ✅ Done | Email + password breach lookup |
|
||||
| Dark web multi-source scanning | DarkWatch | ✅ Done | HIBP, SecurityTrails, Censys, Shodan, forums |
|
||||
| Watch list management | DarkWatch | ✅ Done | Emails, phones, SSN (hashed) |
|
||||
| Scheduled + real-time scanning | DarkWatch | ✅ Done | Tier-based frequency |
|
||||
| Fuzzy matching engine | DarkWatch | ✅ Done | Levenshtein + exact matching |
|
||||
| Severity-scored alert pipeline | DarkWatch | ✅ Done | Dedup pipeline |
|
||||
| PDF report generation | DarkWatch | ✅ Done | Handlebars + PDFKit |
|
||||
| Number reputation (Hiya/Truecaller) | SpamShield | ✅ Done | Circuit breaker pattern |
|
||||
| SMS classification (BERT) | SpamShield | ✅ Done | ML-based spam detection |
|
||||
| Call analysis rule engine | SpamShield | ✅ Done | Multi-layer scoring |
|
||||
| Real-time carrier interception | SpamShield | ⏳ In Progress | Twilio/Plivo integration |
|
||||
| Real-time WebSocket alerts | SpamShield | ✅ Done | Alert broadcasting |
|
||||
| User feedback loop (FP/FN) | SpamShield | ✅ Done | Metadata validation |
|
||||
| Phone validation (E.164) | SpamShield | ✅ Done | Normalization |
|
||||
| Audit logging | SpamShield | ✅ Done | All decisions logged |
|
||||
| SpamShield rate limiting | SpamShield | ⏳ In Progress | Per-endpoint + global |
|
||||
| SpamShield route optimization | SpamShield | ⏳ In Progress | Route consolidation |
|
||||
| Feature flags | All | ✅ Done | Env-variable toggles |
|
||||
| Property record matching | HomeTitle | ✅ Done | Fuzzy string matching |
|
||||
| Change detection (ownership, liens) | HomeTitle | ✅ Done | County deed scanning |
|
||||
| Watchlist management | HomeTitle | ✅ Done | |
|
||||
| Scheduled county deed scanning | HomeTitle | ✅ Done | |
|
||||
| Alert pipeline | HomeTitle | ✅ Done | Severity classification |
|
||||
| Data broker removal requests | RemoveBrokers | ✅ Done | |
|
||||
| Broker API integration | RemoveBrokers | ✅ Done | With caching |
|
||||
| User auth (JWT, RBAC) | Shared | ✅ Done | NextAuth.js |
|
||||
| Family group management | Shared | ✅ Done | |
|
||||
| Stripe subscriptions & billing | Shared | ✅ Done | Tier-based feature gating |
|
||||
| Email (Resend) | Shared | ✅ Done | Transactional + marketing |
|
||||
| Push notifications (FCM/APNs) | Shared | ✅ Done | Android + iOS |
|
||||
| SMS (Twilio) | Shared | ✅ Done | |
|
||||
| Mixpanel analytics (30+ events) | Shared | ✅ Done | KPI tracking |
|
||||
| Datadog APM + Sentry | Shared | ✅ Done | Full observability |
|
||||
| Cross-service event correlation | Shared | ✅ Done | Alert correlation engine |
|
||||
| Browser extension (MV3) | Extension | ✅ Done | Phishing detection |
|
||||
| Mobile app (Expo RN) | Mobile | ✅ Done | iOS + Android |
|
||||
| Shared UI component library | Shared UI | ✅ Done | SolidJS |
|
||||
| CI/CD pipelines | DevOps | ✅ Done | GitHub Actions |
|
||||
| Terraform infrastructure | DevOps | ✅ Done | AWS ECS, RDS, ElastiCache |
|
||||
| Load testing (k6) | DevOps | ✅ Done | VoicePrint + DarkWatch |
|
||||
| Docker + Compose | DevOps | ✅ Done | Dev + prod configs |
|
||||
| Integration tests | QA | ⏳ In Progress | Coverage expanding |
|
||||
| Rate limit tests | QA | ⏳ In Progress | |
|
||||
```
|
||||
kordant/
|
||||
├── web/ # SolidStart monolith (frontend + tRPC backend)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # Page routes (landing, auth, dashboard)
|
||||
│ │ ├── components/ # UI components (primitives, layouts, widgets)
|
||||
│ │ ├── server/ # tRPC routers, services, database, jobs
|
||||
│ │ ├── hooks/ # Solid hooks
|
||||
│ │ ├── lib/ # Shared utilities
|
||||
│ │ └── theme/ # Generated design tokens
|
||||
│ └── Dockerfile
|
||||
├── browser-ext/ # Chrome Manifest V3 extension
|
||||
├── iOS/Kordant/ # SwiftUI native iOS app
|
||||
├── android/ # Jetpack Compose native Android app
|
||||
├── design-tokens/ # Brand tokens (single source of truth)
|
||||
├── docs/ # Brand guidelines, runbooks
|
||||
├── scripts/ # Build and deployment scripts
|
||||
├── tasks/ # Project task tracking
|
||||
├── docker-compose.yml # Local dev (web + postgres + redis)
|
||||
├── docker-compose.prod.yml # Production deployment
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -113,25 +90,24 @@ Backed by ML models (ECAPA-TDNN, BERT) and a real-time alert pipeline, Kordant g
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Language** | TypeScript (Node.js ≥20) |
|
||||
| **API** | Fastify 5 (CORS, Helmet, rate-limit, Swagger, multipart) |
|
||||
| **Frontend** | SolidJS + Vite |
|
||||
| **Mobile** | React Native / Expo SDK 51 |
|
||||
| **Language** | TypeScript (Node.js ≥22) |
|
||||
| **Framework** | SolidStart (SSR + API server) |
|
||||
| **API** | tRPC (type-safe RPC) |
|
||||
| **Database** | PostgreSQL 16 (Drizzle ORM) |
|
||||
| **Cache / Queue** | Redis 7 |
|
||||
| **Styling** | Tailwind CSS + CSS custom properties |
|
||||
| **Mobile iOS** | SwiftUI (native) |
|
||||
| **Mobile Android** | Jetpack Compose (native) |
|
||||
| **Extension** | Chrome Manifest V3 |
|
||||
| **Databases** | PostgreSQL 15/16 (Prisma ORM) + Turso/SQLite (Drizzle) |
|
||||
| **Cache / Queue** | Redis + BullMQ |
|
||||
| **Auth** | NextAuth.js + JWT |
|
||||
| **Auth** | JWT + session cookies |
|
||||
| **Billing** | Stripe |
|
||||
| **Email** | Resend |
|
||||
| **Push** | Firebase Cloud Messaging + APNs |
|
||||
| **SMS** | Twilio |
|
||||
| **Analytics** | Mixpanel / Segment |
|
||||
| **Monitoring** | Datadog APM + Sentry |
|
||||
| **ML Models** | ECAPA-TDNN (voice), BERT (SMS), FAISS (vector index) |
|
||||
| **Infrastructure** | Terraform (AWS ECS Fargate, RDS, ElastiCache, S3, ALB) |
|
||||
| **CI/CD** | GitHub Actions |
|
||||
| **Monorepo** | pnpm workspaces + Turborepo |
|
||||
| **Testing** | Vitest, Jest, k6 |
|
||||
| **Design Tokens** | JSON → generated TS/Swift/XML |
|
||||
| **CI/CD** | Vercel (web) + Docker (scheduler) |
|
||||
| **Monorepo** | pnpm workspaces |
|
||||
| **Testing** | Vitest |
|
||||
|
||||
---
|
||||
|
||||
@@ -139,160 +115,96 @@ Backed by ML models (ECAPA-TDNN, BERT) and a real-time alert pipeline, Kordant g
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- Node.js >= 22.0.0
|
||||
- pnpm >= 9.0.0
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start local infrastructure (Postgres, Redis, Mailhog)
|
||||
docker compose up -d
|
||||
|
||||
# Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your Turso credentials
|
||||
# DATABASE_URL=libsql://your-db.turso.io
|
||||
# DATABASE_AUTH_TOKEN=your-token
|
||||
|
||||
# Run database migrations
|
||||
pnpm db:migrate
|
||||
|
||||
# Start all development servers
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This launches the API server, all microservices, and the web frontend concurrently via Turborepo.
|
||||
The web app runs at `http://localhost:3000`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
## Design Tokens
|
||||
|
||||
All platforms (web, iOS, Android) share the same design tokens defined in `design-tokens/`:
|
||||
|
||||
```
|
||||
design-tokens/
|
||||
├── colors.json # Brand, semantic, background, text, border colors
|
||||
├── typography.json # Font family, scale, weights
|
||||
├── spacing.json # 4px-based spacing scale
|
||||
├── shadows.json # Elevation definitions
|
||||
└── radius.json # Border radius scale
|
||||
```
|
||||
|
||||
Generate platform-specific code:
|
||||
|
||||
```bash
|
||||
# Build all packages and services
|
||||
pnpm build
|
||||
|
||||
# Build individual Docker images
|
||||
docker build -f packages/api/Dockerfile -t kordant-api .
|
||||
docker build -f services/spamshield/Dockerfile -t kordant-spamshield .
|
||||
docker build -f services/darkwatch/Dockerfile -t kordant-darkwatch .
|
||||
docker build -f services/voiceprint/Dockerfile -t kordant-voiceprint .
|
||||
node scripts/generate-tokens.mjs
|
||||
```
|
||||
|
||||
This produces:
|
||||
- `web/src/theme/tokens.ts` — TypeScript constants
|
||||
- `iOS/Kordant/Theme/GeneratedTokens.swift` — SwiftUI colors + spacing
|
||||
- `android/.../res/values/generated_tokens.xml` — Android resources
|
||||
|
||||
See `docs/BRAND_GUIDELINES.md` for full brand guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
## Deployment
|
||||
|
||||
| Component | Platform | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Web app | Vercel | git push auto-deploys |
|
||||
| Database | Turso (managed) | run `pnpm db:migrate` to apply schema changes |
|
||||
| Background jobs | Docker on `pan` | scheduler + Redis containers |
|
||||
|
||||
### Setting up the Scheduler (pan server)
|
||||
|
||||
The background job scheduler (dark web scans, reports, etc.) runs as Docker containers on your `pan` server. Run the setup script from anywhere:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
# From dev machine (SSHs into pan):
|
||||
bash scripts/setup-pan.sh
|
||||
|
||||
# With coverage
|
||||
pnpm test:coverage
|
||||
|
||||
# Individual service tests
|
||||
pnpm test --filter @kordant/spamshield
|
||||
pnpm test --filter @kordant/darkwatch
|
||||
pnpm test --filter @kordant/voiceprint
|
||||
pnpm test --filter @kordant/hometitle
|
||||
|
||||
# Integration & E2E
|
||||
cd packages/integration-tests && pnpm test
|
||||
cd packages/integration-tests && pnpm test:e2e
|
||||
|
||||
# Load tests (requires k6)
|
||||
cd scripts/load-test && ./run-all.sh
|
||||
# Or directly on pan:
|
||||
sudo bash scripts/setup-pan.sh
|
||||
```
|
||||
|
||||
---
|
||||
This installs Docker + Compose, clones the repo to `/opt/kordant`, creates a systemd service, and starts the scheduler. See the script for details and the optional Gitea post-receive hook for auto-deploy on push.
|
||||
|
||||
## Production
|
||||
### Scripts
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (GitHub Actions)
|
||||
|
||||
| Event | Deploy To |
|
||||
|-------|-----------|
|
||||
| Push to `main` | Staging |
|
||||
| GitHub Release created | Production |
|
||||
|
||||
Pipeline stages: `lint` → `typecheck` → `test` → `Docker build` → `push to GHCR` → `Terraform apply` → `ECS deploy` → `health check` → auto-rollback on failure.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
All infrastructure lives in `infra/` and is managed with Terraform:
|
||||
|
||||
- **Compute**: AWS ECS Fargate (API + services + workers)
|
||||
- **Database**: RDS PostgreSQL 15/16
|
||||
- **Cache**: ElastiCache Redis
|
||||
- **Storage**: S3 (reports, audio samples)
|
||||
- **Networking**: VPC, ALB, security groups
|
||||
- **Observability**: CloudWatch + Datadog
|
||||
- **Secrets**: AWS Secrets Manager
|
||||
|
||||
See `infra/README.md` and `infra/ROLLBACK.md` for detailed operational runbooks.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
kordant/
|
||||
├── packages/ # Shared libraries (20 packages)
|
||||
│ ├── api/ # Fastify API server
|
||||
│ ├── core/ # Core shared logic
|
||||
│ ├── db/ # Prisma schemas (v6)
|
||||
│ ├── shared-db/ # Prisma schemas (v5)
|
||||
│ ├── shared-auth/ # NextAuth.js
|
||||
│ ├── shared-billing/ # Stripe subscriptions
|
||||
│ ├── shared-notifications/ # Email, Push, SMS
|
||||
│ ├── shared-analytics/ # Mixpanel/Segment
|
||||
│ ├── shared-ui/ # SolidJS components
|
||||
│ ├── shared-utils/ # Utilities
|
||||
│ ├── types/ # Shared TypeScript types
|
||||
│ ├── mobile/ # React Native / Expo app
|
||||
│ ├── extension/ # Browser extension (MV3)
|
||||
│ ├── jobs/ # BullMQ workers
|
||||
│ ├── monitoring/ # Datadog + Sentry
|
||||
│ ├── report/ # PDF generation
|
||||
│ ├── correlation/ # Event correlation
|
||||
│ ├── mobile-api-client/ # RN API client
|
||||
│ └── integration-tests/ # E2E tests
|
||||
├── services/ # Microservices (5)
|
||||
│ ├── voiceprint/ # Voice cloning detection
|
||||
│ ├── darkwatch/ # Dark web monitoring
|
||||
│ ├── spamshield/ # Spam call/SMS blocking
|
||||
│ ├── hometitle/ # Home title fraud
|
||||
│ └── removebrokers/ # Data broker removal
|
||||
├── infra/ # Terraform (AWS)
|
||||
├── docs/ # Documentation
|
||||
├── plans/ # Product & technical plans
|
||||
├── scripts/ # Utility scripts
|
||||
├── load-tests/ # k6 load test scripts
|
||||
├── assets/ # Ad creative assets
|
||||
└── server/ # Legacy WebSocket server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Location |
|
||||
|----------|----------|
|
||||
| Product Plan | `plans/Kordant-product-plan.md` |
|
||||
| Technical Architecture | `plans/Kordant-technical-architecture.md` |
|
||||
| Infrastructure | `infra/README.md` |
|
||||
| Rollback Runbook | `infra/ROLLBACK.md` |
|
||||
| Stripe Integration | `docs/STRIPE_INTEGRATION.md` |
|
||||
| Push Notifications | `docs/PUSH_NOTIFICATIONS_SETUP.md` |
|
||||
| Mobile Push Integration | `docs/MOBILE_PUSH_INTEGRATION.md` |
|
||||
| Mixpanel Analytics | `docs/MIXPANEL_ANALYTICS.md` |
|
||||
| Code Review Workflow | `kordant-workflow.md` |
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Start web dev server |
|
||||
| `pnpm build` | Build web app for production |
|
||||
| `pnpm test` | Run web tests |
|
||||
| `pnpm lint` | Lint web app |
|
||||
| `pnpm db:migrate` | Run database migrations |
|
||||
| `pnpm db:seed` | Seed database with test data |
|
||||
| `pnpm build:ext` | Build browser extension |
|
||||
| `node scripts/generate-tokens.mjs` | Generate platform design tokens |
|
||||
| `bash scripts/setup-pan.sh` | Deploy scheduler to pan server |
|
||||
|
||||
---
|
||||
|
||||
|
||||
56
android/app/src/main/res/values/generated_tokens.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY -->
|
||||
<!-- Run: node scripts/generate-tokens.mjs -->
|
||||
<resources>
|
||||
<!-- Brand -->
|
||||
<color name="brand_primary">#4F46E5</color>
|
||||
<color name="brand_primary_light">#818CF8</color>
|
||||
<color name="brand_primary_dark">#4338CA</color>
|
||||
<color name="brand_accent">#06B6D4</color>
|
||||
<color name="brand_accent_light">#67E8F9</color>
|
||||
<color name="brand_accent_dark">#0891B2</color>
|
||||
<!-- Semantic -->
|
||||
<!-- Light theme -->
|
||||
<color name="bg_light">#FAFBFC</color>
|
||||
<color name="bg_secondary_light">#F3F4F6</color>
|
||||
<color name="text_primary_light">#111827</color>
|
||||
<color name="text_secondary_light">#6B7280</color>
|
||||
<color name="border_light">#E5E7EB</color>
|
||||
<!-- Dark theme -->
|
||||
<color name="bg_dark">#111827</color>
|
||||
<color name="bg_secondary_dark">#1F2937</color>
|
||||
<color name="text_primary_dark">#F9FAFB</color>
|
||||
<color name="text_secondary_dark">#D1D5DB</color>
|
||||
<color name="border_dark">#374151</color>
|
||||
<!-- Spacing -->
|
||||
<dimen name="spacing_0">0dp</dimen>
|
||||
<dimen name="spacing_xs">4dp</dimen>
|
||||
<dimen name="spacing_sm">8dp</dimen>
|
||||
<dimen name="spacing_md">16dp</dimen>
|
||||
<dimen name="spacing_lg">24dp</dimen>
|
||||
<dimen name="spacing_xl">32dp</dimen>
|
||||
<dimen name="spacing_xxl">48dp</dimen>
|
||||
<dimen name="spacing_xxxl">64dp</dimen>
|
||||
<!-- Corner radius -->
|
||||
<dimen name="corner_none">0dp</dimen>
|
||||
<dimen name="corner_sm">4dp</dimen>
|
||||
<dimen name="corner_md">8dp</dimen>
|
||||
<dimen name="corner_lg">12dp</dimen>
|
||||
<dimen name="corner_xl">16dp</dimen>
|
||||
<dimen name="corner_full">9999dp</dimen>
|
||||
<!-- Typography -->
|
||||
<dimen name="font_caption">12sp</dimen>
|
||||
<dimen name="font_caption_lh">16sp</dimen>
|
||||
<dimen name="font_body">16sp</dimen>
|
||||
<dimen name="font_body_lh">24sp</dimen>
|
||||
<dimen name="font_body_large">18sp</dimen>
|
||||
<dimen name="font_body_large_lh">28sp</dimen>
|
||||
<dimen name="font_headline">20sp</dimen>
|
||||
<dimen name="font_headline_lh">28sp</dimen>
|
||||
<dimen name="font_title">24sp</dimen>
|
||||
<dimen name="font_title_lh">32sp</dimen>
|
||||
<dimen name="font_large_title">32sp</dimen>
|
||||
<dimen name="font_large_title_lh">40sp</dimen>
|
||||
<dimen name="font_display">48sp</dimen>
|
||||
<dimen name="font_display_lh">56sp</dimen>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 207 KiB |
@@ -1,39 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad2" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="60%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#0c1628"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="628" fill="url(#bgGrad2)"/>
|
||||
<rect width="1200" height="5" fill="url(#brandBar)"/>
|
||||
<circle cx="830" cy="314" r="240" fill="#3b82f608"/>
|
||||
<circle cx="830" cy="314" r="180" fill="#3b82f606"/>
|
||||
<circle cx="830" cy="314" r="220" fill="none" stroke="#3b82f615" stroke-width="1" stroke-dasharray="8 8"/>
|
||||
|
||||
<!-- Digital shield icon (large, right side) -->
|
||||
<g transform="translate(830, 314)">
|
||||
<path d="M-70,-60 L70,-60 L75,20 Q75,60 40,80 L0,95 L-40,80 Q-75,60 -75,20 Z" fill="none" stroke="url(#shieldGrad)" stroke-width="3"/>
|
||||
<path d="M-30,-10 L0,25 L35,-20" fill="none" stroke="#22c55e" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="0" y="130" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">AI-Powered Protection</text>
|
||||
</g>
|
||||
|
||||
<!-- Left side: text -->
|
||||
<text x="60" y="220" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="#f1f5f9">Your Family Deserves</text>
|
||||
<text x="60" y="280" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="#06b6d4">AI Protection</text>
|
||||
|
||||
<text x="60" y="340" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8">Real-time AI voice clone detection</text>
|
||||
<text x="60" y="368" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8">Dark web monitoring • Spam blocking</text>
|
||||
|
||||
<rect x="60" y="410" width="200" height="52" rx="26" fill="#3b82f6"/>
|
||||
<text x="160" y="442" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="#f1f5f9" text-anchor="middle">Join the Waitlist</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 105 KiB |
@@ -1,45 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="750" viewBox="0 0 600 750">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad3" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="600" height="750" fill="url(#bgGrad3)"/>
|
||||
<rect width="600" height="5" fill="url(#brandBar)"/>
|
||||
|
||||
<!-- Phone icon -->
|
||||
<g transform="translate(300, 260)">
|
||||
<rect x="-60" y="-100" width="120" height="200" rx="18" fill="none" stroke="#3b82f6" stroke-width="3"/>
|
||||
<circle cx="0" cy="80" r="6" fill="#3b82f6"/>
|
||||
<!-- Sound waves -->
|
||||
<path d="M-30,-30 Q-50,-10 -30,10" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<path d="M-20,-45 Q-65,-10 -20,25" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M-10,-60 Q-80,-10 -10,40" fill="none" stroke="#06b6d4" stroke-width="2.5" stroke-linecap="round" filter="url(#glow)"/>
|
||||
</g>
|
||||
|
||||
<!-- Warning indicator -->
|
||||
<g transform="translate(300, 80)">
|
||||
<path d="M0,-30 L-20,0 L20,0 Z" fill="#f59e0b"/>
|
||||
<circle cx="0" cy="10" r="4" fill="#f59e0b"/>
|
||||
</g>
|
||||
|
||||
<text x="300" y="420" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#f1f5f9" text-anchor="middle">Voice Clone</text>
|
||||
<text x="300" y="460" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="#06b6d4" text-anchor="middle">Detection</text>
|
||||
|
||||
<text x="300" y="510" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">AI detects synthetic voices</text>
|
||||
<text x="300" y="535" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">in real time with 99.7% accuracy</text>
|
||||
|
||||
<rect x="200" y="580" width="200" height="50" rx="25" fill="#3b82f6"/>
|
||||
<text x="300" y="611" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Learn How We Detect It</text>
|
||||
|
||||
<text x="300" y="710" font-family="system-ui, sans-serif" font-size="13" fill="#64748b" text-anchor="middle">Kordant — AI-Powered Identity Protection</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 268 KiB |
@@ -1,45 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="1200" fill="url(#bgGrad)"/>
|
||||
<rect width="1200" height="6" fill="url(#brandBar)"/>
|
||||
<text x="600" y="160" font-family="system-ui, sans-serif" font-size="52" font-weight="700" fill="#f1f5f9" text-anchor="middle">3 Protections, 1 Platform</text>
|
||||
<text x="600" y="220" font-family="system-ui, sans-serif" font-size="24" fill="#94a3b8" text-anchor="middle">AI-Powered Identity Protection for Everyone</text>
|
||||
<rect x="120" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||
<g transform="translate(260, 420)">
|
||||
<circle cx="0" cy="0" r="50" fill="#06b6d422" stroke="#06b6d4" stroke-width="2"/>
|
||||
<path d="M0,-40 Q30,-35 40,-10 Q45,5 35,20 L25,30 L0,40 L-25,30 L-35,20 Q-45,5 -40,-10 Q-30,-35 0,-40 Z" fill="none" stroke="#06b6d4" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M-12,0 L-4,8 L12,-10" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="260" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">VoicePrint</text>
|
||||
<text x="260" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">AI Voice Clone Detection</text>
|
||||
<rect x="460" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||
<g transform="translate(600, 420)">
|
||||
<circle cx="0" cy="0" r="50" fill="#3b82f622" stroke="#3b82f6" stroke-width="2"/>
|
||||
<path d="M-35,-30 L35,-30 L40,10 Q40,30 25,40 L0,45 L-25,40 Q-40,30 -40,10 Z" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M0,5 L0,25 M-10,15 L10,15" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="600" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">DarkWatch</text>
|
||||
<text x="600" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">Dark Web Monitoring</text>
|
||||
<rect x="800" y="300" width="280" height="320" rx="16" fill="#1a2332" stroke="#1e293b" stroke-width="1.5"/>
|
||||
<g transform="translate(940, 420)">
|
||||
<circle cx="0" cy="0" r="50" fill="#22c55e22" stroke="#22c55e" stroke-width="2"/>
|
||||
<path d="M-40,-10 Q-40,-40 0,-40 Q40,-40 40,-10 Q40,15 20,30 L0,40 L-20,30 Q-40,15 -40,-10 Z" fill="none" stroke="#22c55e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M-15,0 L-5,10 L18,-12" fill="none" stroke="#f1f5f9" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="940" y="510" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">SpamShield</text>
|
||||
<text x="940" y="540" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">Spam Call & Text Blocking</text>
|
||||
<text x="600" y="1100" font-family="system-ui, sans-serif" font-size="18" fill="#64748b" text-anchor="middle">Join 1,000+ Early Adopters</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,633 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Kordant ad creative SVGs for Google Display and Meta campaigns."""
|
||||
|
||||
import os
|
||||
|
||||
OUT = os.path.join(os.path.dirname(__file__))
|
||||
|
||||
# Brand colors
|
||||
DARK_BG = "#0a0f1e"
|
||||
CARD_BG = "#1a2332"
|
||||
TEXT_PRIMARY = "#f1f5f9"
|
||||
TEXT_SECONDARY = "#94a3b8"
|
||||
TEXT_MUTED = "#64748b"
|
||||
ACCENT_BLUE = "#3b82f6"
|
||||
ACCENT_CYAN = "#06b6d4"
|
||||
SUCCESS = "#22c55e"
|
||||
ERROR = "#ef4444"
|
||||
WARNING = "#f59e0b"
|
||||
BORDER = "#1e293b"
|
||||
|
||||
def shield_logo_svg(size=40, x=0, y=0):
|
||||
return f'''<g transform="translate({x},{y})">
|
||||
<circle cx="{size//2}" cy="{size//2}" r="{size//2}" fill="url(shieldGrad)"/>
|
||||
<path d="M{size//2-10},{size//2-8} L{size//2+10},{size//2-8} L{size//2+10},{size//2+6} Q{size//2},{size//2+14} {size//2},{size//2+14} Q{size//2},{size//2+14} {size//2-10},{size//2+6} Z" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="2.5"/>
|
||||
<path d="M{size//2-4},{size//2-2} L{size//2},{size//2+4} L{size//2+7},{size//2-5}" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>'''
|
||||
|
||||
def brand_bar(w, h):
|
||||
return f'''<rect width="{w}" height="{h}" fill="url(brandBar)"/>'''
|
||||
|
||||
def safe_text(text, max_len=80):
|
||||
return text[:max_len] if len(text) > max_len else text
|
||||
|
||||
# ============================================================
|
||||
# GOOGLE DISPLAY ASSETS
|
||||
# ============================================================
|
||||
|
||||
def gd_square():
|
||||
"""1:1 (1200x1200) — '3 Protections, 1 Platform' three-icon panel"""
|
||||
w, h = 1200, 1200
|
||||
icon_size = 100
|
||||
box_w, box_h = 280, 320
|
||||
gap = 60
|
||||
total_w = 3 * box_w + 2 * gap
|
||||
start_x = (w - total_w) // 2
|
||||
top_y = 300
|
||||
|
||||
icons_data = [
|
||||
("VoicePrint", "AI Voice Clone Detection", ACCENT_CYAN, [
|
||||
"M0,-40 Q30,-35 40,-10 Q45,5 35,20 L25,30 L0,40 L-25,30 L-35,20 Q-45,5 -40,-10 Q-30,-35 0,-40 Z",
|
||||
"M-12,0 L-4,8 L12,-10"
|
||||
]),
|
||||
("DarkWatch", "Dark Web Monitoring", ACCENT_BLUE, [
|
||||
"M-35,-30 L35,-30 L40,10 Q40,30 25,40 L0,45 L-25,40 Q-40,30 -40,10 Z",
|
||||
"M0,5 L0,25 M-10,15 L10,15"
|
||||
]),
|
||||
("SpamShield", "Spam Call & Text Blocking", SUCCESS, [
|
||||
"M-40,-10 Q-40,-40 0,-40 Q40,-40 40,-10 Q40,15 20,30 L0,40 L-20,30 Q-40,15 -40,-10 Z",
|
||||
"M-15,0 L-5,10 L18,-12"
|
||||
]),
|
||||
]
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgGrad)"/>
|
||||
{brand_bar(w, 6)}
|
||||
<text x="{w//2}" y="160" font-family="system-ui, sans-serif" font-size="52" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">3 Protections, 1 Platform</text>
|
||||
<text x="{w//2}" y="220" font-family="system-ui, sans-serif" font-size="24" fill="{TEXT_SECONDARY}" text-anchor="middle">AI-Powered Identity Protection for Everyone</text>'''
|
||||
|
||||
for i, (name, desc, color, paths) in enumerate(icons_data):
|
||||
cx = start_x + i * (box_w + gap) + box_w // 2
|
||||
cy = top_y + box_h // 2
|
||||
|
||||
svg += f'''
|
||||
<rect x="{start_x + i * (box_w + gap)}" y="{top_y}" width="{box_w}" height="{box_h}" rx="16" fill="{CARD_BG}" stroke="{BORDER}" stroke-width="1.5"/>'''
|
||||
svg += f'''
|
||||
<g transform="translate({cx}, {cy - 40})">
|
||||
<circle cx="0" cy="0" r="50" fill="{color}22" stroke="{color}" stroke-width="2"/>
|
||||
<path d="{paths[0]}" fill="none" stroke="{color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="{paths[1]}" fill="none" stroke="{TEXT_PRIMARY}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>'''
|
||||
svg += f'''
|
||||
<text x="{cx}" y="{cy + 50}" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">{name}</text>
|
||||
<text x="{cx}" y="{cy + 80}" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">{desc}</text>'''
|
||||
|
||||
svg += f'''
|
||||
<text x="{w//2}" y="{h - 100}" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_MUTED}" text-anchor="middle">Join 1,000+ Early Adopters</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
def gd_landscape():
|
||||
"""1.91:1 (1200x628) — 'Your Family Deserves AI Protection' family + shield"""
|
||||
w, h = 1200, 628
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad2" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="60%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="100%" stop-color="#0c1628"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgGrad2)"/>
|
||||
{brand_bar(w, 5)}
|
||||
<circle cx="830" cy="314" r="240" fill="{ACCENT_BLUE}08"/>
|
||||
<circle cx="830" cy="314" r="180" fill="{ACCENT_BLUE}06"/>
|
||||
<circle cx="830" cy="314" r="220" fill="none" stroke="{ACCENT_BLUE}15" stroke-width="1" stroke-dasharray="8 8"/>
|
||||
|
||||
<!-- Digital shield icon (large, right side) -->
|
||||
<g transform="translate(830, 314)">
|
||||
<path d="M-70,-60 L70,-60 L75,20 Q75,60 40,80 L0,95 L-40,80 Q-75,60 -75,20 Z" fill="none" stroke="url(#shieldGrad)" stroke-width="3"/>
|
||||
<path d="M-30,-10 L0,25 L35,-20" fill="none" stroke="{SUCCESS}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="0" y="130" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_SECONDARY}" text-anchor="middle">AI-Powered Protection</text>
|
||||
</g>
|
||||
|
||||
<!-- Left side: text -->
|
||||
<text x="60" y="220" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="{TEXT_PRIMARY}">Your Family Deserves</text>
|
||||
<text x="60" y="280" font-family="system-ui, sans-serif" font-size="44" font-weight="700" fill="{ACCENT_CYAN}">AI Protection</text>
|
||||
|
||||
<text x="60" y="340" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}">Real-time AI voice clone detection</text>
|
||||
<text x="60" y="368" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}">Dark web monitoring • Spam blocking</text>
|
||||
|
||||
<rect x="60" y="410" width="200" height="52" rx="26" fill="{ACCENT_BLUE}"/>
|
||||
<text x="160" y="442" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Join the Waitlist</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
def gd_portrait():
|
||||
"""4:5 (600x750) — 'Voice Clone Detection' phone call visualization"""
|
||||
w, h = 600, 750
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad3" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgGrad3)"/>
|
||||
{brand_bar(w, 5)}
|
||||
|
||||
<!-- Phone icon -->
|
||||
<g transform="translate(300, 260)">
|
||||
<rect x="-60" y="-100" width="120" height="200" rx="18" fill="none" stroke="{ACCENT_BLUE}" stroke-width="3"/>
|
||||
<circle cx="0" cy="80" r="6" fill="{ACCENT_BLUE}"/>
|
||||
<!-- Sound waves -->
|
||||
<path d="M-30,-30 Q-50,-10 -30,10" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
||||
<path d="M-20,-45 Q-65,-10 -20,25" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M-10,-60 Q-80,-10 -10,40" fill="none" stroke="{ACCENT_CYAN}" stroke-width="2.5" stroke-linecap="round" filter="url(#glow)"/>
|
||||
</g>
|
||||
|
||||
<!-- Warning indicator -->
|
||||
<g transform="translate(300, 80)">
|
||||
<path d="M0,-30 L-20,0 L20,0 Z" fill="{WARNING}"/>
|
||||
<circle cx="0" cy="10" r="4" fill="{WARNING}"/>
|
||||
</g>
|
||||
|
||||
<text x="{w//2}" y="420" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Voice Clone</text>
|
||||
<text x="{w//2}" y="460" font-family="system-ui, sans-serif" font-size="34" font-weight="700" fill="{ACCENT_CYAN}" text-anchor="middle">Detection</text>
|
||||
|
||||
<text x="{w//2}" y="510" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">AI detects synthetic voices</text>
|
||||
<text x="{w//2}" y="535" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">in real time with 99.7% accuracy</text>
|
||||
|
||||
<rect x="200" y="580" width="200" height="50" rx="25" fill="{ACCENT_BLUE}"/>
|
||||
<text x="300" y="611" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Learn How We Detect It</text>
|
||||
|
||||
<text x="{w//2}" y="{h - 40}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_MUTED}" text-anchor="middle">Kordant — AI-Powered Identity Protection</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
# ============================================================
|
||||
# META CREATIVE A: Voice Clone Threat
|
||||
# ============================================================
|
||||
|
||||
def meta_a_1x1():
|
||||
"""1:1 (1080x1080) — split-screen family / AI distortion"""
|
||||
w, h = 1080, 1080
|
||||
half = w // 2
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgGradL" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0a1528"/>
|
||||
<stop offset="100%" stop-color="#0f1d35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgGradR" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="distortGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="{ERROR}66"/>
|
||||
<stop offset="100%" stop-color="{ERROR}22"/>
|
||||
</linearGradient>
|
||||
<filter id="glitch">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="8" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Left panel: normal family -->
|
||||
<rect x="0" y="0" width="{half}" height="{h}" fill="url(#bgGradL)"/>
|
||||
<circle cx="{half//2}" cy="280" r="60" fill="{ACCENT_BLUE}30" stroke="{ACCENT_BLUE}" stroke-width="2"/>
|
||||
<circle cx="{half//2 - 60}" cy="220" r="40" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||
<circle cx="{half//2 + 70}" cy="230" r="35" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||
<circle cx="{half//2 - 30}" cy="360" r="45" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||
<rect x="{half//2 - 70}" y="420" width="140" height="180" rx="10" fill="{ACCENT_BLUE}15" stroke="{ACCENT_BLUE}" stroke-width="1.5" opacity="0.6"/>
|
||||
<text x="{half//2}" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family</text>
|
||||
<text x="{half//2}" y="710" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_SECONDARY}" text-anchor="middle">Real & Unfiltered</text>
|
||||
|
||||
<!-- Center divider with phone icon -->
|
||||
<rect x="{half - 2}" y="0" width="4" height="{h}" fill="{BORDER}"/>
|
||||
<g transform="translate({half}, {h//2 - 60})">
|
||||
<rect x="-25" y="-50" width="50" height="100" rx="10" fill="{ACCENT_BLUE}" opacity="0.3"/>
|
||||
<path d="M-10,-10 Q-20,0 -10,10" fill="none" stroke="{ERROR}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M0,-20 Q-25,0 0,20" fill="none" stroke="{ERROR}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M10,-30 Q-30,0 10,30" fill="none" stroke="{ERROR}" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- Right panel: distorted/AI -->
|
||||
<rect x="{half}" y="0" width="{half}" height="{h}" fill="url(#bgGradR)"/>
|
||||
<g filter="url(#glitch)">
|
||||
<circle cx="{half + half//2}" cy="280" r="60" fill="{ERROR}30" stroke="{ERROR}" stroke-width="2"/>
|
||||
<circle cx="{half + half//2 - 60}" cy="220" r="40" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||
<circle cx="{half + half//2 + 70}" cy="230" r="35" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||
<circle cx="{half + half//2 - 30}" cy="360" r="45" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||
<rect x="{half + half//2 - 70}" y="420" width="140" height="180" rx="10" fill="{ERROR}15" stroke="{ERROR}" stroke-width="1.5" opacity="0.6"/>
|
||||
</g>
|
||||
<text x="{half + half//2}" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{ERROR}" text-anchor="middle">AI Clone</text>
|
||||
<text x="{half + half//2}" y="710" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_MUTED}" text-anchor="middle">Synthetic & Dangerous</text>
|
||||
|
||||
<!-- Bottom brand bar -->
|
||||
<rect x="0" y="{h - 90}" width="{w}" height="90" fill="{CARD_BG}"/>
|
||||
<text x="{w//2}" y="{h - 55}" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family's Voice, Protected</text>
|
||||
<text x="{w//2}" y="{h - 28}" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_SECONDARY}" text-anchor="middle">Kordant detects AI voice cloning with 99.7% accuracy</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
def meta_a_191():
|
||||
"""1.91:1 (1200x628) — split-screen family / AI distortion"""
|
||||
w, h = 1200, 628
|
||||
half = w // 2
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgL" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0a1528"/>
|
||||
<stop offset="100%" stop-color="#0f1d35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgR" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<filter id="glitch2">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="6" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="{half}" height="{h}" fill="url(#bgL)"/>
|
||||
<circle cx="{half//2}" cy="{h//2 - 30}" r="35" fill="{ACCENT_BLUE}30" stroke="{ACCENT_BLUE}" stroke-width="2"/>
|
||||
<circle cx="{half//2 - 50}" cy="{h//2 - 80}" r="25" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||
<circle cx="{half//2 + 55}" cy="{h//2 - 75}" r="22" fill="{ACCENT_BLUE}20" stroke="{ACCENT_BLUE}" stroke-width="1.5"/>
|
||||
<text x="{half//2}" y="{h//2 + 60}" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Family</text>
|
||||
<rect x="0" y="{h - 50}" width="{half}" height="50" fill="{CARD_BG}"/>
|
||||
<text x="{half//2}" y="{h - 22}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_SECONDARY}" text-anchor="middle">Real voice, real moment</text>
|
||||
|
||||
<rect x="{half - 1}" y="0" width="3" height="{h}" fill="{BORDER}"/>
|
||||
<rect x="{half}" y="0" width="{half}" height="{h}" fill="url(#bgR)"/>
|
||||
<g filter="url(#glitch2)">
|
||||
<circle cx="{half + half//2}" cy="{h//2 - 30}" r="35" fill="{ERROR}30" stroke="{ERROR}" stroke-width="2"/>
|
||||
<circle cx="{half + half//2 - 50}" cy="{h//2 - 80}" r="25" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||
<circle cx="{half + half//2 + 55}" cy="{h//2 - 75}" r="22" fill="{ERROR}20" stroke="{ERROR}" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="{half + half//2}" y="{h//2 + 60}" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="{ERROR}" text-anchor="middle">AI Clone</text>
|
||||
<rect x="{half}" y="{h - 50}" width="{half}" height="50" fill="{ERROR}22"/>
|
||||
<text x="{half + half//2}" y="{h - 22}" font-family="system-ui, sans-serif" font-size="13" fill="{TEXT_MUTED}" text-anchor="middle">Synthetic voice clone</text>
|
||||
|
||||
<text x="30" y="50" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="{TEXT_PRIMARY}">Your Family's Voice, Protected</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
# ============================================================
|
||||
# META CREATIVE B: Dark Web
|
||||
# ============================================================
|
||||
|
||||
def meta_b_1x1():
|
||||
"""1:1 (1080x1080) — dark terminal HUD aesthetic"""
|
||||
w, h = 1080, 1080
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgTerm" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#050a05"/>
|
||||
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{SUCCESS}"/>
|
||||
<stop offset="100%" stop-color="#16a34a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgTerm)"/>
|
||||
|
||||
<!-- Matrix-like grid lines -->
|
||||
<g stroke="{SUCCESS}10" stroke-width="0.5">
|
||||
<line x1="0" y1="100" x2="{w}" y2="100"/>
|
||||
<line x1="0" y1="200" x2="{w}" y2="200"/>
|
||||
<line x1="0" y1="300" x2="{w}" y2="300"/>
|
||||
<line x1="0" y1="400" x2="{w}" y2="400"/>
|
||||
<line x1="0" y1="500" x2="{w}" y2="500"/>
|
||||
<line x1="0" y1="600" x2="{w}" y2="600"/>
|
||||
<line x1="0" y1="700" x2="{w}" y2="700"/>
|
||||
<line x1="0" y1="800" x2="{w}" y2="800"/>
|
||||
<line x1="0" y1="900" x2="{w}" y2="900"/>
|
||||
<line x1="0" y1="1000" x2="{w}" y2="1000"/>
|
||||
</g>
|
||||
|
||||
<!-- Terminal window frame -->
|
||||
<rect x="100" y="200" width="{w - 200}" height="500" rx="12" fill="#0d1f0d" stroke="{SUCCESS}30" stroke-width="1.5"/>
|
||||
<rect x="100" y="200" width="{w - 200}" height="40" rx="12" fill="#143014"/>
|
||||
<rect x="100" y="228" width="{w - 200}" height="12" fill="#143014"/>
|
||||
<circle cx="130" cy="220" r="6" fill="{ERROR}"/>
|
||||
<circle cx="155" cy="220" r="6" fill="{WARNING}"/>
|
||||
<circle cx="180" cy="220" r="6" fill="{SUCCESS}"/>
|
||||
<text x="200" y="225" font-family="monospace" font-size="14" fill="{TEXT_MUTED}">darkwatch@kordant:~$</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="130" y="280" font-family="monospace" font-size="16" fill="{WARNING}">> Scanning 150+ dark web marketplaces...</text>
|
||||
<text x="130" y="320" font-family="monospace" font-size="16" fill="{WARNING}">> Analyzing breach databases...</text>
|
||||
|
||||
<text x="130" y="380" font-family="monospace" font-size="18" font-weight="bold" fill="{ERROR}">! ALERT: MATCHES FOUND</text>
|
||||
|
||||
<rect x="130" y="410" width="320" height="28" fill="{ERROR}15"/>
|
||||
<text x="140" y="430" font-family="monospace" font-size="15" fill="{ERROR}">email:***@gmail.com — 3 breaches</text>
|
||||
|
||||
<rect x="130" y="445" width="320" height="28" fill="{ERROR}15"/>
|
||||
<text x="140" y="465" font-family="monospace" font-size="15" fill="{ERROR}">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||
|
||||
<rect x="130" y="480" width="320" height="28" fill="{ERROR}15"/>
|
||||
<text x="140" y="500" font-family="monospace" font-size="15" fill="{ERROR}">ssn:***-**-6781 — 1 breach</text>
|
||||
|
||||
<text x="130" y="550" font-family="monospace" font-size="16" fill="{SUCCESS}">> Total exposures found: 5,284</text>
|
||||
<text x="130" y="580" font-family="monospace" font-size="16" fill="{ACCENT_CYAN}">> Run scan on your data? [Y/n] _</text>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<rect x="340" y="750" width="400" height="56" rx="28" fill="{SUCCESS}"/>
|
||||
<text x="540" y="785" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||
|
||||
<text x="{w//2}" y="860" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_MUTED}" text-anchor="middle">Kordant DarkWatch — 24/7 Dark Web Monitoring</text>
|
||||
|
||||
<text x="{w//2}" y="920" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">5K+ Exposures Found.</text>
|
||||
<text x="{w//2}" y="960" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="{SUCCESS}" text-anchor="middle">What About Yours?</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
def meta_b_45():
|
||||
"""4:5 (1080x1350) — dark terminal HUD"""
|
||||
w, h = 1080, 1350
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgB45" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#050a05"/>
|
||||
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{SUCCESS}"/>
|
||||
<stop offset="100%" stop-color="#16a34a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgB45)"/>
|
||||
|
||||
<!-- Terminal -->
|
||||
<rect x="80" y="250" width="{w - 160}" height="520" rx="12" fill="#0d1f0d" stroke="{SUCCESS}30" stroke-width="1.5"/>
|
||||
<rect x="80" y="250" width="{w - 160}" height="40" rx="12" fill="#143014"/>
|
||||
<rect x="80" y="278" width="{w - 160}" height="12" fill="#143014"/>
|
||||
<circle cx="110" cy="270" r="6" fill="{ERROR}"/>
|
||||
<circle cx="135" cy="270" r="6" fill="{WARNING}"/>
|
||||
<circle cx="160" cy="270" r="6" fill="{SUCCESS}"/>
|
||||
<text x="180" y="275" font-family="monospace" font-size="14" fill="{TEXT_MUTED}">darkwatch@kordant:~$</text>
|
||||
|
||||
<text x="110" y="330" font-family="monospace" font-size="16" fill="{WARNING}">> Scanning 150+ dark web marketplaces...</text>
|
||||
<text x="110" y="360" font-family="monospace" font-size="16" fill="{WARNING}">> Cross-referencing databases...</text>
|
||||
|
||||
<text x="110" y="415" font-family="monospace" font-size="18" font-weight="bold" fill="{ERROR}">! ALERT: DATA EXPOSED</text>
|
||||
|
||||
<rect x="110" y="445" width="350" height="28" fill="{ERROR}15"/>
|
||||
<text x="120" y="465" font-family="monospace" font-size="14" fill="{ERROR}">email:***@gmail.com — 3 breaches</text>
|
||||
|
||||
<rect x="110" y="480" width="350" height="28" fill="{ERROR}15"/>
|
||||
<text x="120" y="500" font-family="monospace" font-size="14" fill="{ERROR}">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||
|
||||
<rect x="110" y="515" width="350" height="28" fill="{ERROR}15"/>
|
||||
<text x="120" y="535" font-family="monospace" font-size="14" fill="{ERROR}">ssn:***-**-6781 — 1 breach</text>
|
||||
|
||||
<rect x="110" y="550" width="350" height="28" fill="{ERROR}15"/>
|
||||
<text x="120" y="570" font-family="monospace" font-size="14" fill="{ERROR}">Address:*** Oak St — 1 breach</text>
|
||||
|
||||
<text x="110" y="625" font-family="monospace" font-size="16" fill="{SUCCESS}">> Total exposures monitored: 5,284</text>
|
||||
<text x="110" y="660" font-family="monospace" font-size="16" fill="{ACCENT_CYAN}">> Run scan on your data? [Y/n] _</text>
|
||||
|
||||
<text x="{w//2}" y="840" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Your Data May Already Be</text>
|
||||
<text x="{w//2}" y="885" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="{ERROR}" text-anchor="middle">For Sale on the Dark Web</text>
|
||||
|
||||
<text x="{w//2}" y="940" font-family="system-ui, sans-serif" font-size="16" fill="{TEXT_SECONDARY}" text-anchor="middle">Kordant scans 150+ marketplaces 24/7 and alerts you instantly</text>
|
||||
|
||||
<rect x="{w//2 - 175}" y="1000" width="350" height="56" rx="28" fill="{SUCCESS}"/>
|
||||
<text x="{w//2}" y="1035" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||
|
||||
<text x="{w//2}" y="{h - 50}" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_MUTED}" text-anchor="middle">Kordant — AI-Powered Identity Protection for Everyone</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
# ============================================================
|
||||
# META CREATIVE C: 3 Protections
|
||||
# ============================================================
|
||||
|
||||
def meta_c_1x1():
|
||||
"""1:1 (1080x1080) — three-panel layout"""
|
||||
w, h = 1080, 1080
|
||||
panel_w, panel_h = 300, 400
|
||||
gap = 30
|
||||
total_w = 3 * panel_w + 2 * gap
|
||||
start_x = (w - total_w) // 2
|
||||
top_y = 280
|
||||
|
||||
panels = [
|
||||
("VoicePrint", ACCENT_CYAN, "AI Voice Clone\nDetection", "Real-time detection\nof synthetic voices\nwith 99.7% accuracy"),
|
||||
("DarkWatch", ACCENT_BLUE, "Dark Web\nMonitoring", "24/7 scanning of\n150+ marketplaces\nfor your data"),
|
||||
("SpamShield", SUCCESS, "Spam Call &\nText Blocking", "AI-powered filtering\nof spam calls\nand text messages"),
|
||||
]
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgC)"/>
|
||||
{brand_bar(w, 6)}
|
||||
|
||||
<text x="{w//2}" y="140" font-family="system-ui, sans-serif" font-size="42" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">3 Ways Kordant Protects Your Family</text>
|
||||
<text x="{w//2}" y="185" font-family="system-ui, sans-serif" font-size="18" fill="{TEXT_SECONDARY}" text-anchor="middle">VoicePrint + DarkWatch + SpamShield</text>'''
|
||||
|
||||
for i, (name, color, title, desc) in enumerate(panels):
|
||||
px = start_x + i * (panel_w + gap)
|
||||
py = top_y
|
||||
cx = px + panel_w // 2
|
||||
icon_y = py + 60
|
||||
|
||||
svg += f'''
|
||||
<rect x="{px}" y="{py}" width="{panel_w}" height="{panel_h}" rx="16" fill="{CARD_BG}" stroke="{color}30" stroke-width="1.5"/>
|
||||
<circle cx="{cx}" cy="{icon_y}" r="40" fill="{color}22" stroke="{color}" stroke-width="2"/>
|
||||
<text x="{cx}" y="{icon_y + 5}" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="{color}" text-anchor="middle">{name}</text>'''
|
||||
|
||||
lines = title.split('\n')
|
||||
for li, line in enumerate(lines):
|
||||
svg += f'''
|
||||
<text x="{cx}" y="{icon_y + 60 + li * 32}" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">{line}</text>'''
|
||||
|
||||
desc_lines = desc.split('\n')
|
||||
for li, line in enumerate(desc_lines):
|
||||
svg += f'''
|
||||
<text x="{cx}" y="{icon_y + 120 + li * 25}" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_SECONDARY}" text-anchor="middle">{line}</text>'''
|
||||
|
||||
svg += f'''
|
||||
<rect x="{w//2 - 135}" y="760" width="270" height="52" rx="26" fill="{ACCENT_BLUE}"/>
|
||||
<text x="{w//2}" y="793" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Join the Waitlist</text>
|
||||
|
||||
<text x="{w//2}" y="870" font-family="system-ui, sans-serif" font-size="15" fill="{TEXT_MUTED}" text-anchor="middle">Three critical protections, one powerful platform</text>
|
||||
<text x="{w//2}" y="900" font-family="system-ui, sans-serif" font-size="14" fill="{TEXT_MUTED}" text-anchor="middle">Start free. Launching soon.</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
# ============================================================
|
||||
# META CREATIVE D: Family Protection
|
||||
# ============================================================
|
||||
|
||||
def meta_d_base(w, h, small=False):
|
||||
"""Family protection — multi-generational family with digital shield overlay"""
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
|
||||
<defs>
|
||||
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}30"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_BLUE}00"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="{DARK_BG}"/>
|
||||
<stop offset="60%" stop-color="#0d1a30"/>
|
||||
<stop offset="100%" stop-color="{DARK_BG}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="{ACCENT_BLUE}"/>
|
||||
<stop offset="100%" stop-color="{ACCENT_CYAN}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{w}" height="{h}" fill="url(#bgD)"/>
|
||||
{brand_bar(w, 5)}
|
||||
|
||||
<!-- Digital shield overlay -->
|
||||
<circle cx="{w//2}" cy="{h//2}" r="{min(w,h)*0.38}" fill="url(#shieldGlow)"/>
|
||||
<g transform="translate({w//2}, {h//2 - 30})">
|
||||
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="{SUCCESS}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Family figures (simplified) -->
|
||||
{g_family_figures(w, h)}
|
||||
|
||||
<text x="{w//2}" y="{h - 160}" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="{TEXT_PRIMARY}" text-anchor="middle">Protect Your Whole Family</text>
|
||||
<text x="{w//2}" y="{h - 115}" font-family="system-ui, sans-serif" font-size="17" fill="{TEXT_SECONDARY}" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||
<text x="{w//2}" y="{h - 85}" font-family="system-ui, sans-serif" font-size="17" fill="{TEXT_SECONDARY}" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||
|
||||
<rect x="{w//2 - 115}" y="{h - 60}" width="230" height="46" rx="23" fill="{ACCENT_BLUE}"/>
|
||||
<text x="{w//2}" y="{h - 33}" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="{TEXT_PRIMARY}" text-anchor="middle">Protect My Family</text>
|
||||
</svg>'''
|
||||
return svg
|
||||
|
||||
|
||||
def g_family_figures(w, h):
|
||||
"""Generate simple family figure silhouettes."""
|
||||
cx = w // 2
|
||||
base_y = h // 2 + 60
|
||||
return f'''
|
||||
<!-- Grandparent L -->
|
||||
<circle cx="{cx - 110}" cy="{base_y - 55}" r="22" fill="#33415580"/>
|
||||
<rect x="{cx - 125}" y="{base_y - 30}" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
<!-- Parent L -->
|
||||
<circle cx="{cx - 50}" cy="{base_y - 70}" r="25" fill="#47556980"/>
|
||||
<rect x="{cx - 68}" y="{base_y - 42}" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Child -->
|
||||
<circle cx="{cx + 15}" cy="{base_y - 60}" r="18" fill="#64748b80"/>
|
||||
<rect x="{cx + 2}" y="{base_y - 40}" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||
|
||||
<!-- Parent R -->
|
||||
<circle cx="{cx + 80}" cy="{base_y - 70}" r="25" fill="#47556980"/>
|
||||
<rect x="{cx + 62}" y="{base_y - 42}" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Grandparent R -->
|
||||
<circle cx="{cx + 140}" cy="{base_y - 55}" r="22" fill="#33415580"/>
|
||||
<rect x="{cx + 125}" y="{base_y - 30}" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
'''
|
||||
|
||||
|
||||
def meta_d_1x1():
|
||||
return meta_d_base(1080, 1080)
|
||||
|
||||
def meta_d_191():
|
||||
return meta_d_base(1200, 628)
|
||||
|
||||
def meta_d_45():
|
||||
return meta_d_base(1080, 1350)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GENERATE ALL
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
assets = [
|
||||
# Google Display
|
||||
("gd_square_1200x1200.svg", gd_square()),
|
||||
("gd_landscape_1200x628.svg", gd_landscape()),
|
||||
("gd_portrait_600x750.svg", gd_portrait()),
|
||||
# Meta Creative A
|
||||
("meta_a_1x1_1080x1080.svg", meta_a_1x1()),
|
||||
("meta_a_191_1200x628.svg", meta_a_191()),
|
||||
# Meta Creative B
|
||||
("meta_b_1x1_1080x1080.svg", meta_b_1x1()),
|
||||
("meta_b_45_1080x1350.svg", meta_b_45()),
|
||||
# Meta Creative C
|
||||
("meta_c_1x1_1080x1080.svg", meta_c_1x1()),
|
||||
# Meta Creative D
|
||||
("meta_d_1x1_1080x1080.svg", meta_d_1x1()),
|
||||
("meta_d_191_1200x628.svg", meta_d_191()),
|
||||
("meta_d_45_1080x1350.svg", meta_d_45()),
|
||||
]
|
||||
|
||||
for name, svg in assets:
|
||||
path = os.path.join(OUT, name)
|
||||
with open(path, 'w') as f:
|
||||
f.write(svg)
|
||||
print(f"Created: {name} ({len(svg)} bytes)")
|
||||
|
||||
print(f"\nDone. Generated {len(assets)} SVG files in {OUT}")
|
||||
|
Before Width: | Height: | Size: 99 KiB |
@@ -1,120 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="50%" stop-color="#111827"/>
|
||||
<stop offset="100%" stop-color="#0f1729"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="phoneGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e293b"/>
|
||||
<stop offset="100%" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<filter id="softGlow">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="627" fill="url(#bg)"/>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<g opacity="0.05" stroke="#3b82f6" stroke-width="0.5">
|
||||
<line x1="0" y1="100" x2="1200" y2="100"/>
|
||||
<line x1="0" y1="200" x2="1200" y2="200"/>
|
||||
<line x1="0" y1="300" x2="1200" y2="300"/>
|
||||
<line x1="0" y1="400" x2="1200" y2="400"/>
|
||||
<line x1="0" y1="500" x2="1200" y2="500"/>
|
||||
<line x1="200" y1="0" x2="200" y2="627"/>
|
||||
<line x1="400" y1="0" x2="400" y2="627"/>
|
||||
<line x1="600" y1="0" x2="600" y2="627"/>
|
||||
<line x1="800" y1="0" x2="800" y2="627"/>
|
||||
<line x1="1000" y1="0" x2="1000" y2="627"/>
|
||||
</g>
|
||||
|
||||
<!-- Decorative circle top-right -->
|
||||
<circle cx="1050" cy="100" r="300" fill="#3b82f6" opacity="0.04"/>
|
||||
<circle cx="1100" cy="50" r="200" fill="#06b6d4" opacity="0.03"/>
|
||||
|
||||
<!-- Left content area -->
|
||||
<!-- Headline -->
|
||||
<text x="60" y="200" font-family="DejaVu Sans, sans-serif" font-size="36" font-weight="bold" fill="#f1f5f9">
|
||||
<tspan x="60" dy="0">AI Voice Cloning</tspan>
|
||||
<tspan x="60" dy="48" fill="#3b82f6">Is the New Phishing Threat</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Body copy -->
|
||||
<text x="60" y="330" font-family="DejaVu Sans, sans-serif" font-size="16" fill="#94a3b8">
|
||||
<tspan x="60" dy="0">Cybercriminals are using AI-generated voice clones</tspan>
|
||||
<tspan x="60" dy="28">to impersonate executives and family members.</tspan>
|
||||
<tspan x="60" dy="28">Kordant detects synthetic voices in real time.</tspan>
|
||||
</text>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<rect x="60" y="420" width="180" height="50" rx="25" fill="url(#accent)"/>
|
||||
<text x="150" y="452" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Learn More →</text>
|
||||
|
||||
<!-- Right side: Visual area -->
|
||||
<!-- Large shield background glow -->
|
||||
<circle cx="800" cy="330" r="180" fill="#3b82f6" opacity="0.06" filter="url(#softGlow)"/>
|
||||
|
||||
<!-- Shield icon -->
|
||||
<g transform="translate(680, 200)">
|
||||
<path d="M120 20 L220 60 L220 110 Q220 170 120 210 Q20 170 20 110 L20 60 Z" fill="url(#shieldGrad)" opacity="0.9"/>
|
||||
</g>
|
||||
|
||||
<!-- Phone silhouette -->
|
||||
<g transform="translate(710, 260)">
|
||||
<rect x="0" y="0" width="50" height="90" rx="10" fill="url(#phoneGrad)" stroke="#334155" stroke-width="1.5"/>
|
||||
<rect x="15" y="8" width="20" height="3" rx="1.5" fill="#3b82f6" opacity="0.5"/>
|
||||
<circle cx="25" cy="68" r="8" fill="none" stroke="#334155" stroke-width="1"/>
|
||||
<line x1="10" y1="20" x2="40" y2="20" stroke="#334155" stroke-width="0.5"/>
|
||||
<line x1="10" y1="25" x2="35" y2="25" stroke="#334155" stroke-width="0.5"/>
|
||||
<line x1="10" y1="30" x2="30" y2="30" stroke="#334155" stroke-width="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Sound wave lines from phone -->
|
||||
<g stroke="#3b82f6" stroke-width="2" fill="none" opacity="0.5" filter="url(#glow)">
|
||||
<path d="M770 290 Q790 280 770 270"/>
|
||||
<path d="M780 300 Q810 285 780 270"/>
|
||||
<path d="M790 310 Q830 290 790 270"/>
|
||||
</g>
|
||||
|
||||
<!-- Executive silhouette -->
|
||||
<g transform="translate(820, 230)" opacity="0.15">
|
||||
<ellipse cx="40" cy="25" rx="25" ry="25" fill="#f1f5f9"/>
|
||||
<rect x="0" y="50" width="80" height="100" rx="10" fill="#f1f5f9"/>
|
||||
<rect x="-5" y="60" width="15" height="60" rx="5" fill="#f1f5f9"/>
|
||||
<rect x="70" y="60" width="15" height="60" rx="5" fill="#f1f5f9"/>
|
||||
</g>
|
||||
|
||||
<!-- Digital shield overlay on right -->
|
||||
<g transform="translate(750, 170)" opacity="0.12">
|
||||
<path d="M50 0 L100 30 L100 70 Q100 120 50 150 Q0 120 0 70 L0 30 Z" fill="none" stroke="#3b82f6" stroke-width="3"/>
|
||||
<path d="M50 0 L100 30 L100 70 Q100 120 50 150 Q0 120 0 70 L0 30 Z" fill="none" stroke="#06b6d4" stroke-width="1" transform="translate(5, 5)"/>
|
||||
</g>
|
||||
|
||||
<!-- Bottom branding bar -->
|
||||
<rect x="0" y="577" width="1200" height="50" fill="#0a0f1e" opacity="0.8"/>
|
||||
<line x1="0" y1="577" x2="1200" y2="577" stroke="#3b82f6" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Kordant logo -->
|
||||
<g transform="translate(60, 590)">
|
||||
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">Kordant</text>
|
||||
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 111 KiB |
@@ -1,132 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#05080f"/>
|
||||
<stop offset="50%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="dangerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef4444"/>
|
||||
<stop offset="100%" stop-color="#dc2626"/>
|
||||
</linearGradient>
|
||||
<filter id="redGlow">
|
||||
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<filter id="softGlow">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="627" fill="url(#bg)"/>
|
||||
|
||||
<!-- Terminal scan lines -->
|
||||
<g opacity="0.03">
|
||||
<line x1="0" y1="0" x2="1200" y2="0" stroke="#22c55e" stroke-width="1"/>
|
||||
<line x1="0" y1="4" x2="1200" y2="4" stroke="#22c55e" stroke-width="0.5"/>
|
||||
<line x1="0" y1="8" x2="1200" y2="8" stroke="#22c55e" stroke-width="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Matrix rain effect lines -->
|
||||
<g stroke="#22c55e" stroke-width="0.5" opacity="0.04">
|
||||
<line x1="100" y1="0" x2="100" y2="627"/>
|
||||
<line x1="300" y1="0" x2="300" y2="627"/>
|
||||
<line x1="500" y1="0" x2="500" y2="627"/>
|
||||
<line x1="700" y1="0" x2="700" y2="627"/>
|
||||
<line x1="900" y1="0" x2="900" y2="627"/>
|
||||
<line x1="1100" y1="0" x2="1100" y2="627"/>
|
||||
</g>
|
||||
|
||||
<!-- Top-right decorative glow -->
|
||||
<circle cx="1050" cy="100" r="250" fill="#ef4444" opacity="0.03"/>
|
||||
|
||||
<!-- Terminal window - left side -->
|
||||
<g transform="translate(60, 120)">
|
||||
<rect x="0" y="0" width="520" height="340" rx="8" fill="#0d1117" stroke="#1e293b" stroke-width="1.5"/>
|
||||
<!-- Window chrome -->
|
||||
<rect x="0" y="0" width="520" height="32" rx="8" fill="#161b22"/>
|
||||
<rect x="0" y="16" width="520" height="16" fill="#161b22"/>
|
||||
<circle cx="20" cy="16" r="5" fill="#ef4444"/>
|
||||
<circle cx="37" cy="16" r="5" fill="#eab308"/>
|
||||
<circle cx="54" cy="16" r="5" fill="#22c55e"/>
|
||||
<text x="260" y="21" font-family="monospace" font-size="11" fill="#64748b" text-anchor="middle">DarkWatch Terminal — Scan Results</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="16" y="60" font-family="monospace" font-size="12" fill="#22c55e">$ ./darkwatch --scan --deep</text>
|
||||
<text x="16" y="82" font-family="monospace" font-size="12" fill="#64748b">Scanning 178 dark web marketplaces...</text>
|
||||
<text x="16" y="104" font-family="monospace" font-size="12" fill="#64748b">Checking credentials associated with target@email.com</text>
|
||||
<text x="16" y="126" font-family="monospace" font-size="12" fill="#64748b">Checking phone: +1 (555) ***-****</text>
|
||||
<text x="16" y="148" font-family="monospace" font-size="12" fill="#22c55e">Scan complete. Found 12 exposures.</text>
|
||||
|
||||
<!-- Alert box -->
|
||||
<rect x="16" y="170" width="488" height="44" rx="4" fill="#450a0a" stroke="#ef4444" stroke-width="1" opacity="0.9"/>
|
||||
<circle cx="32" cy="192" r="5" fill="#ef4444" filter="url(#redGlow)"/>
|
||||
<text x="44" y="196" font-family="monospace" font-size="12" fill="#fca5a5" font-weight="bold">CRITICAL: Email + password exposed on 3 marketplaces</text>
|
||||
|
||||
<!-- Exposed data rows -->
|
||||
<rect x="16" y="222" width="488" height="30" rx="2" fill="#1a2332" opacity="0.5"/>
|
||||
<text x="24" y="241" font-family="monospace" font-size="11" fill="#94a3b8">email@example.com</text>
|
||||
<text x="280" y="241" font-family="monospace" font-size="11" fill="#ef4444">P@ssw0rd123!</text>
|
||||
<text x="460" y="241" font-family="monospace" font-size="11" fill="#f59e0b">LEAKED</text>
|
||||
|
||||
<rect x="16" y="255" width="488" height="30" rx="2" fill="#1a2332" opacity="0.3"/>
|
||||
<text x="24" y="274" font-family="monospace" font-size="11" fill="#94a3b8">+1 (555) 234-5678</text>
|
||||
<text x="280" y="274" font-family="monospace" font-size="11" fill="#ef4444">[HASHED]</text>
|
||||
<text x="460" y="274" font-family="monospace" font-size="11" fill="#f59e0b">LEAKED</text>
|
||||
|
||||
<rect x="16" y="288" width="488" height="30" rx="2" fill="#1a2332" opacity="0.5"/>
|
||||
<text x="24" y="307" font-family="monospace" font-size="11" fill="#94a3b8">SSN: ***-**-1234</text>
|
||||
<text x="280" y="307" font-family="monospace" font-size="11" fill="#ef4444">[REDACTED]</text>
|
||||
<text x="460" y="307" font-family="monospace" font-size="11" fill="#ef4444">HIGH RISK</text>
|
||||
</g>
|
||||
|
||||
<!-- Right side: Headline & CTA -->
|
||||
<text x="660" y="200" font-family="DejaVu Sans, sans-serif" font-size="36" font-weight="bold" fill="#f1f5f9">
|
||||
<tspan x="660" dy="0">Your Personal Data</tspan>
|
||||
<tspan x="660" dy="48" fill="#ef4444">Is on the Dark Web</tspan>
|
||||
</text>
|
||||
|
||||
<text x="660" y="320" font-family="DejaVu Sans, sans-serif" font-size="16" fill="#94a3b8">
|
||||
<tspan x="660" dy="0">70% of data breaches expose employee</tspan>
|
||||
<tspan x="660" dy="28">personal contact info. Kordant's DarkWatch</tspan>
|
||||
<tspan x="660" dy="28">scans 100+ marketplaces daily for</tspan>
|
||||
<tspan x="660" dy="28">exposed emails, phones, and SSNs.</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Stats row -->
|
||||
<g transform="translate(660, 400)">
|
||||
<rect x="0" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||
<text x="55" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#ef4444" text-anchor="middle">178</text>
|
||||
<text x="55" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Marketplaces</text>
|
||||
|
||||
<rect x="125" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||
<text x="180" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#f59e0b" text-anchor="middle">24/7</text>
|
||||
<text x="180" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Monitoring</text>
|
||||
|
||||
<rect x="250" y="0" width="110" height="60" rx="8" fill="#1a2332" stroke="#1e293b" stroke-width="1"/>
|
||||
<text x="305" y="25" font-family="DejaVu Sans, sans-serif" font-size="20" font-weight="bold" fill="#22c55e" text-anchor="middle">99.7%</text>
|
||||
<text x="305" y="48" font-family="DejaVu Sans, sans-serif" font-size="10" fill="#64748b" text-anchor="middle">Accuracy</text>
|
||||
</g>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<rect x="660" y="490" width="200" height="50" rx="25" fill="url(#accent)"/>
|
||||
<text x="760" y="522" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Monitor Your Data →</text>
|
||||
|
||||
<!-- Bottom branding bar -->
|
||||
<rect x="0" y="577" width="1200" height="50" fill="#05080f" opacity="0.9"/>
|
||||
<line x1="0" y1="577" x2="1200" y2="577" stroke="#3b82f6" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Kordant logo -->
|
||||
<g transform="translate(60, 590)">
|
||||
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">Kordant</text>
|
||||
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -1,162 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 627" width="1200" height="627">
|
||||
<defs>
|
||||
<linearGradient id="bgLeft" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#111827"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgRight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1a0f0a"/>
|
||||
<stop offset="100%" stop-color="#2d1a10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="warmAccent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#f97316"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="dividerGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0"/>
|
||||
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.3"/>
|
||||
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="softGlow">
|
||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- LEFT HALF: Professional -->
|
||||
|
||||
<!-- Background left -->
|
||||
<rect x="0" y="0" width="600" height="577" fill="url(#bgLeft)"/>
|
||||
|
||||
<!-- Subtle grid left -->
|
||||
<g opacity="0.04" stroke="#3b82f6" stroke-width="0.5">
|
||||
<line x1="0" y1="100" x2="600" y2="100"/>
|
||||
<line x1="0" y1="200" x2="600" y2="200"/>
|
||||
<line x1="0" y1="300" x2="600" y2="300"/>
|
||||
<line x1="0" y1="400" x2="600" y2="400"/>
|
||||
<line x1="0" y1="500" x2="600" y2="500"/>
|
||||
<line x1="150" y1="0" x2="150" y2="577"/>
|
||||
<line x1="300" y1="0" x2="300" y2="577"/>
|
||||
<line x1="450" y1="0" x2="450" y2="577"/>
|
||||
</g>
|
||||
|
||||
<!-- Office desk illustration -->
|
||||
<g transform="translate(100, 160)" opacity="0.12">
|
||||
<!-- Monitor -->
|
||||
<rect x="30" y="20" width="100" height="65" rx="3" fill="#3b82f6"/>
|
||||
<rect x="35" y="25" width="90" height="55" rx="1" fill="#0a0f1e"/>
|
||||
<!-- Screen content -->
|
||||
<rect x="40" y="35" width="40" height="3" rx="1" fill="#3b82f6" opacity="0.5"/>
|
||||
<rect x="40" y="42" width="60" height="3" rx="1" fill="#3b82f6" opacity="0.3"/>
|
||||
<rect x="40" y="49" width="25" height="3" rx="1" fill="#3b82f6" opacity="0.3"/>
|
||||
<!-- Stand -->
|
||||
<rect x="55" y="85" width="50" height="5" rx="1" fill="#1e293b"/>
|
||||
<rect x="70" y="90" width="20" height="10" fill="#1e293b"/>
|
||||
<!-- Desk -->
|
||||
<rect x="0" y="100" width="180" height="5" rx="1" fill="#1e293b"/>
|
||||
</g>
|
||||
|
||||
<!-- Professional icon label -->
|
||||
<g transform="translate(60, 130)">
|
||||
<circle cx="20" cy="20" r="20" fill="#3b82f6" opacity="0.15"/>
|
||||
<path d="M12 28 L12 20 L20 16 L28 20 L28 28 Z" fill="#3b82f6" opacity="0.8"/>
|
||||
<text x="50" y="25" font-family="DejaVu Sans, sans-serif" font-size="14" font-weight="bold" fill="#3b82f6">Work Protection</text>
|
||||
</g>
|
||||
|
||||
<!-- Professional features -->
|
||||
<g transform="translate(60, 280)" opacity="0.7">
|
||||
<circle cx="8" cy="8" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="13" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">AI voice clone detection</text>
|
||||
<circle cx="8" cy="33" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="38" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Dark web monitoring</text>
|
||||
<circle cx="8" cy="58" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="63" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Spam call/text blocking</text>
|
||||
<circle cx="8" cy="83" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="88" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#94a3b8">Enterprise-grade security</text>
|
||||
</g>
|
||||
|
||||
<!-- RIGHT HALF: Family -->
|
||||
|
||||
<!-- Background right -->
|
||||
<rect x="600" y="0" width="600" height="577" fill="url(#bgRight)"/>
|
||||
|
||||
<!-- Warm glow background -->
|
||||
<circle cx="850" cy="250" r="200" fill="#f59e0b" opacity="0.04"/>
|
||||
|
||||
<!-- Family illustration -->
|
||||
<g transform="translate(730, 180)" opacity="0.12">
|
||||
<!-- Adult 1 -->
|
||||
<ellipse cx="40" cy="20" rx="18" ry="18" fill="#f59e0b"/>
|
||||
<rect x="15" y="38" width="50" height="65" rx="8" fill="#f59e0b"/>
|
||||
<!-- Adult 2 -->
|
||||
<ellipse cx="120" cy="20" rx="18" ry="18" fill="#f59e0b"/>
|
||||
<rect x="95" y="38" width="50" height="65" rx="8" fill="#f59e0b"/>
|
||||
<!-- Child 1 -->
|
||||
<ellipse cx="80" cy="55" rx="14" ry="14" fill="#f59e0b"/>
|
||||
<rect x="64" y="69" width="32" height="40" rx="6" fill="#f59e0b"/>
|
||||
<!-- Child 2 -->
|
||||
<ellipse cx="160" cy="55" rx="14" ry="14" fill="#f97316"/>
|
||||
<rect x="144" y="69" width="32" height="35" rx="6" fill="#f97316"/>
|
||||
<!-- Shield over all -->
|
||||
<path d="M80 10 L160 40 L160 75 Q160 110 80 135 Q0 110 0 75 L0 40 Z" fill="none" stroke="#f59e0b" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Family icon label -->
|
||||
<g transform="translate(630, 130)">
|
||||
<circle cx="20" cy="20" r="20" fill="#f59e0b" opacity="0.15"/>
|
||||
<path d="M12 16 A4 4 0 1 1 12 24 A4 4 0 1 1 12 16z" fill="#f59e0b" opacity="0.8"/>
|
||||
<path d="M8 25 Q12 30 20 30 Q28 30 32 25" fill="#f59e0b" opacity="0.8"/>
|
||||
<text x="50" y="25" font-family="DejaVu Sans, sans-serif" font-size="14" font-weight="bold" fill="#f59e0b">Family Safety</text>
|
||||
</g>
|
||||
|
||||
<!-- Family features -->
|
||||
<g transform="translate(630, 280)" opacity="0.7">
|
||||
<circle cx="8" cy="8" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="13" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Unlimited family members</text>
|
||||
<circle cx="8" cy="33" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="38" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Senior scam protection</text>
|
||||
<circle cx="8" cy="58" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="63" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">Real-time alerts to family</text>
|
||||
<circle cx="8" cy="83" r="4" fill="#22c55e"/>
|
||||
<text x="20" y="88" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#d4a574">24/7 support for all members</text>
|
||||
</g>
|
||||
|
||||
<!-- Center divider -->
|
||||
<line x1="600" y1="50" x2="600" y2="527" stroke="url(#dividerGrad)" stroke-width="2"/>
|
||||
|
||||
<!-- Unified by Kordant badge -->
|
||||
<g transform="translate(380, 370)">
|
||||
<rect x="0" y="0" width="440" height="60" rx="30" fill="#1a2332" stroke="#334155" stroke-width="1" opacity="0.9"/>
|
||||
<path d="M25 15 L45 28 L45 40 Q45 50 25 55 Q5 50 5 40 L5 28 Z" fill="url(#accent)" opacity="0.9"/>
|
||||
<path d="M19 30 L23 30 L23 34 L27 34 L27 38 L23 38 L23 42 L19 42 L19 38 L15 38 L15 34 L19 34 Z" fill="white" opacity="0.9"/>
|
||||
<text x="55" y="35" font-family="DejaVu Sans, sans-serif" font-size="18" font-weight="bold" fill="#f1f5f9">Unified by</text>
|
||||
<text x="155" y="35" font-family="DejaVu Sans, sans-serif" font-size="18" font-weight="bold" fill="#3b82f6">Kordant</text>
|
||||
</g>
|
||||
|
||||
<!-- Headline at center-top -->
|
||||
<text x="600" y="100" font-family="DejaVu Sans, sans-serif" font-size="34" font-weight="bold" fill="#f1f5f9" text-anchor="middle">
|
||||
<tspan x="600" dy="0">One Platform.</tspan>
|
||||
<tspan x="600" dy="44" fill="#3b82f6">Work Protection +</tspan>
|
||||
<tspan x="600" dy="44" fill="#f59e0b">Family Safety.</tspan>
|
||||
</text>
|
||||
|
||||
<!-- CTA -->
|
||||
<rect x="460" y="500" width="280" height="50" rx="25" fill="url(#accent)"/>
|
||||
<text x="600" y="532" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">Join 1,000+ Early Adopters →</text>
|
||||
|
||||
<!-- Bottom branding bar -->
|
||||
<rect x="0" y="577" width="1200" height="50" fill="#05080f" opacity="0.9"/>
|
||||
<line x1="0" y1="577" x2="1200" y2="577" stroke="url(#accent)" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Kordant logo -->
|
||||
<g transform="translate(60, 590)">
|
||||
<path d="M15 2 L28 12 L28 22 Q28 32 15 38 Q2 32 2 22 L2 12 Z" fill="url(#accent)" opacity="0.9"/>
|
||||
<path d="M11 18 L15 18 L15 22 L19 22 L19 26 L15 26 L15 30 L11 30 L11 26 L7 26 L7 22 L11 22 Z" fill="white" opacity="0.9"/>
|
||||
<text x="38" y="26" font-family="DejaVu Sans, sans-serif" font-size="16" font-weight="bold" fill="#f1f5f9">Kordant</text>
|
||||
<text x="115" y="26" font-family="DejaVu Sans, sans-serif" font-size="11" fill="#64748b">AI-Powered Identity Protection for Everyone</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@@ -1,40 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||
<defs>
|
||||
<linearGradient id="bgL" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0a1528"/>
|
||||
<stop offset="100%" stop-color="#0f1d35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgR" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<filter id="glitch2">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="6" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="600" height="628" fill="url(#bgL)"/>
|
||||
<circle cx="300" cy="284" r="35" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"/>
|
||||
<circle cx="250" cy="234" r="25" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<circle cx="355" cy="239" r="22" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<text x="300" y="374" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family</text>
|
||||
<rect x="0" y="578" width="600" height="50" fill="#1a2332"/>
|
||||
<text x="300" y="606" font-family="system-ui, sans-serif" font-size="13" fill="#94a3b8" text-anchor="middle">Real voice, real moment</text>
|
||||
|
||||
<rect x="599" y="0" width="3" height="628" fill="#1e293b"/>
|
||||
<rect x="600" y="0" width="600" height="628" fill="url(#bgR)"/>
|
||||
<g filter="url(#glitch2)">
|
||||
<circle cx="900" cy="284" r="35" fill="#ef444430" stroke="#ef4444" stroke-width="2"/>
|
||||
<circle cx="850" cy="234" r="25" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||
<circle cx="955" cy="239" r="22" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||
</g>
|
||||
<text x="900" y="374" font-family="system-ui, sans-serif" font-size="20" font-weight="600" fill="#ef4444" text-anchor="middle">AI Clone</text>
|
||||
<rect x="600" y="578" width="600" height="50" fill="#ef444422"/>
|
||||
<text x="900" y="606" font-family="system-ui, sans-serif" font-size="13" fill="#64748b" text-anchor="middle">Synthetic voice clone</text>
|
||||
|
||||
<text x="30" y="50" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#f1f5f9">Your Family's Voice, Protected</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 193 KiB |
@@ -1,60 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<linearGradient id="bgGradL" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0a1528"/>
|
||||
<stop offset="100%" stop-color="#0f1d35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgGradR" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1a0a0a"/>
|
||||
<stop offset="100%" stop-color="#2d0f0f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="distortGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ef444466"/>
|
||||
<stop offset="100%" stop-color="#ef444422"/>
|
||||
</linearGradient>
|
||||
<filter id="glitch">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="8" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Left panel: normal family -->
|
||||
<rect x="0" y="0" width="540" height="1080" fill="url(#bgGradL)"/>
|
||||
<circle cx="270" cy="280" r="60" fill="#3b82f630" stroke="#3b82f6" stroke-width="2"/>
|
||||
<circle cx="210" cy="220" r="40" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<circle cx="340" cy="230" r="35" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<circle cx="240" cy="360" r="45" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<rect x="200" y="420" width="140" height="180" rx="10" fill="#3b82f615" stroke="#3b82f6" stroke-width="1.5" opacity="0.6"/>
|
||||
<text x="270" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family</text>
|
||||
<text x="270" y="710" font-family="system-ui, sans-serif" font-size="15" fill="#94a3b8" text-anchor="middle">Real & Unfiltered</text>
|
||||
|
||||
<!-- Center divider with phone icon -->
|
||||
<rect x="538" y="0" width="4" height="1080" fill="#1e293b"/>
|
||||
<g transform="translate(540, 480)">
|
||||
<rect x="-25" y="-50" width="50" height="100" rx="10" fill="#3b82f6" opacity="0.3"/>
|
||||
<path d="M-10,-10 Q-20,0 -10,10" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M0,-20 Q-25,0 0,20" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M10,-30 Q-30,0 10,30" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- Right panel: distorted/AI -->
|
||||
<rect x="540" y="0" width="540" height="1080" fill="url(#bgGradR)"/>
|
||||
<g filter="url(#glitch)">
|
||||
<circle cx="810" cy="280" r="60" fill="#ef444430" stroke="#ef4444" stroke-width="2"/>
|
||||
<circle cx="750" cy="220" r="40" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||
<circle cx="880" cy="230" r="35" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||
<circle cx="780" cy="360" r="45" fill="#ef444420" stroke="#ef4444" stroke-width="1.5"/>
|
||||
<rect x="740" y="420" width="140" height="180" rx="10" fill="#ef444415" stroke="#ef4444" stroke-width="1.5" opacity="0.6"/>
|
||||
</g>
|
||||
<text x="810" y="680" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#ef4444" text-anchor="middle">AI Clone</text>
|
||||
<text x="810" y="710" font-family="system-ui, sans-serif" font-size="15" fill="#64748b" text-anchor="middle">Synthetic & Dangerous</text>
|
||||
|
||||
<!-- Bottom brand bar -->
|
||||
<rect x="0" y="990" width="1080" height="90" fill="#1a2332"/>
|
||||
<text x="540" y="1025" font-family="system-ui, sans-serif" font-size="22" font-weight="600" fill="#f1f5f9" text-anchor="middle">Your Family's Voice, Protected</text>
|
||||
<text x="540" y="1052" font-family="system-ui, sans-serif" font-size="15" fill="#94a3b8" text-anchor="middle">Kordant detects AI voice cloning with 99.7% accuracy</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 243 KiB |
@@ -1,63 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<linearGradient id="bgTerm" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#050a05"/>
|
||||
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#22c55e"/>
|
||||
<stop offset="100%" stop-color="#16a34a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="1080" fill="url(#bgTerm)"/>
|
||||
|
||||
<!-- Matrix-like grid lines -->
|
||||
<g stroke="#22c55e10" stroke-width="0.5">
|
||||
<line x1="0" y1="100" x2="1080" y2="100"/>
|
||||
<line x1="0" y1="200" x2="1080" y2="200"/>
|
||||
<line x1="0" y1="300" x2="1080" y2="300"/>
|
||||
<line x1="0" y1="400" x2="1080" y2="400"/>
|
||||
<line x1="0" y1="500" x2="1080" y2="500"/>
|
||||
<line x1="0" y1="600" x2="1080" y2="600"/>
|
||||
<line x1="0" y1="700" x2="1080" y2="700"/>
|
||||
<line x1="0" y1="800" x2="1080" y2="800"/>
|
||||
<line x1="0" y1="900" x2="1080" y2="900"/>
|
||||
<line x1="0" y1="1000" x2="1080" y2="1000"/>
|
||||
</g>
|
||||
|
||||
<!-- Terminal window frame -->
|
||||
<rect x="100" y="200" width="880" height="500" rx="12" fill="#0d1f0d" stroke="#22c55e30" stroke-width="1.5"/>
|
||||
<rect x="100" y="200" width="880" height="40" rx="12" fill="#143014"/>
|
||||
<rect x="100" y="228" width="880" height="12" fill="#143014"/>
|
||||
<circle cx="130" cy="220" r="6" fill="#ef4444"/>
|
||||
<circle cx="155" cy="220" r="6" fill="#f59e0b"/>
|
||||
<circle cx="180" cy="220" r="6" fill="#22c55e"/>
|
||||
<text x="200" y="225" font-family="monospace" font-size="14" fill="#64748b">darkwatch@kordant:~$</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="130" y="280" font-family="monospace" font-size="16" fill="#f59e0b">> Scanning 150+ dark web marketplaces...</text>
|
||||
<text x="130" y="320" font-family="monospace" font-size="16" fill="#f59e0b">> Analyzing breach databases...</text>
|
||||
|
||||
<text x="130" y="380" font-family="monospace" font-size="18" font-weight="bold" fill="#ef4444">! ALERT: MATCHES FOUND</text>
|
||||
|
||||
<rect x="130" y="410" width="320" height="28" fill="#ef444415"/>
|
||||
<text x="140" y="430" font-family="monospace" font-size="15" fill="#ef4444">email:***@gmail.com — 3 breaches</text>
|
||||
|
||||
<rect x="130" y="445" width="320" height="28" fill="#ef444415"/>
|
||||
<text x="140" y="465" font-family="monospace" font-size="15" fill="#ef4444">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||
|
||||
<rect x="130" y="480" width="320" height="28" fill="#ef444415"/>
|
||||
<text x="140" y="500" font-family="monospace" font-size="15" fill="#ef4444">ssn:***-**-6781 — 1 breach</text>
|
||||
|
||||
<text x="130" y="550" font-family="monospace" font-size="16" fill="#22c55e">> Total exposures found: 5,284</text>
|
||||
<text x="130" y="580" font-family="monospace" font-size="16" fill="#06b6d4">> Run scan on your data? [Y/n] _</text>
|
||||
|
||||
<!-- Bottom CTA -->
|
||||
<rect x="340" y="750" width="400" height="56" rx="28" fill="#22c55e"/>
|
||||
<text x="540" y="785" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||
|
||||
<text x="540" y="860" font-family="system-ui, sans-serif" font-size="16" fill="#64748b" text-anchor="middle">Kordant DarkWatch — 24/7 Dark Web Monitoring</text>
|
||||
|
||||
<text x="540" y="920" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="#f1f5f9" text-anchor="middle">5K+ Exposures Found.</text>
|
||||
<text x="540" y="960" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="#22c55e" text-anchor="middle">What About Yours?</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 297 KiB |
@@ -1,52 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350" viewBox="0 0 1080 1350">
|
||||
<defs>
|
||||
<linearGradient id="bgB45" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#050a05"/>
|
||||
<stop offset="100%" stop-color="#0a1a0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#22c55e"/>
|
||||
<stop offset="100%" stop-color="#16a34a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="1350" fill="url(#bgB45)"/>
|
||||
|
||||
<!-- Terminal -->
|
||||
<rect x="80" y="250" width="920" height="520" rx="12" fill="#0d1f0d" stroke="#22c55e30" stroke-width="1.5"/>
|
||||
<rect x="80" y="250" width="920" height="40" rx="12" fill="#143014"/>
|
||||
<rect x="80" y="278" width="920" height="12" fill="#143014"/>
|
||||
<circle cx="110" cy="270" r="6" fill="#ef4444"/>
|
||||
<circle cx="135" cy="270" r="6" fill="#f59e0b"/>
|
||||
<circle cx="160" cy="270" r="6" fill="#22c55e"/>
|
||||
<text x="180" y="275" font-family="monospace" font-size="14" fill="#64748b">darkwatch@kordant:~$</text>
|
||||
|
||||
<text x="110" y="330" font-family="monospace" font-size="16" fill="#f59e0b">> Scanning 150+ dark web marketplaces...</text>
|
||||
<text x="110" y="360" font-family="monospace" font-size="16" fill="#f59e0b">> Cross-referencing databases...</text>
|
||||
|
||||
<text x="110" y="415" font-family="monospace" font-size="18" font-weight="bold" fill="#ef4444">! ALERT: DATA EXPOSED</text>
|
||||
|
||||
<rect x="110" y="445" width="350" height="28" fill="#ef444415"/>
|
||||
<text x="120" y="465" font-family="monospace" font-size="14" fill="#ef4444">email:***@gmail.com — 3 breaches</text>
|
||||
|
||||
<rect x="110" y="480" width="350" height="28" fill="#ef444415"/>
|
||||
<text x="120" y="500" font-family="monospace" font-size="14" fill="#ef4444">phone:+1 (555) ***-8842 — 2 breaches</text>
|
||||
|
||||
<rect x="110" y="515" width="350" height="28" fill="#ef444415"/>
|
||||
<text x="120" y="535" font-family="monospace" font-size="14" fill="#ef4444">ssn:***-**-6781 — 1 breach</text>
|
||||
|
||||
<rect x="110" y="550" width="350" height="28" fill="#ef444415"/>
|
||||
<text x="120" y="570" font-family="monospace" font-size="14" fill="#ef4444">Address:*** Oak St — 1 breach</text>
|
||||
|
||||
<text x="110" y="625" font-family="monospace" font-size="16" fill="#22c55e">> Total exposures monitored: 5,284</text>
|
||||
<text x="110" y="660" font-family="monospace" font-size="16" fill="#06b6d4">> Run scan on your data? [Y/n] _</text>
|
||||
|
||||
<text x="540" y="840" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="#f1f5f9" text-anchor="middle">Your Data May Already Be</text>
|
||||
<text x="540" y="885" font-family="system-ui, sans-serif" font-size="30" font-weight="700" fill="#ef4444" text-anchor="middle">For Sale on the Dark Web</text>
|
||||
|
||||
<text x="540" y="940" font-family="system-ui, sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">Kordant scans 150+ marketplaces 24/7 and alerts you instantly</text>
|
||||
|
||||
<rect x="365" y="1000" width="350" height="56" rx="28" fill="#22c55e"/>
|
||||
<text x="540" y="1035" font-family="system-ui, sans-serif" font-size="20" font-weight="700" fill="#050a05" text-anchor="middle">Scan Your Email Free</text>
|
||||
|
||||
<text x="540" y="1300" font-family="system-ui, sans-serif" font-size="14" fill="#64748b" text-anchor="middle">Kordant — AI-Powered Identity Protection for Everyone</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 323 KiB |
@@ -1,46 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="100%" stop-color="#050812"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="1080" fill="url(#bgC)"/>
|
||||
<rect width="1080" height="6" fill="url(#brandBar)"/>
|
||||
|
||||
<text x="540" y="140" font-family="system-ui, sans-serif" font-size="42" font-weight="700" fill="#f1f5f9" text-anchor="middle">3 Ways Kordant Protects Your Family</text>
|
||||
<text x="540" y="185" font-family="system-ui, sans-serif" font-size="18" fill="#94a3b8" text-anchor="middle">VoicePrint + DarkWatch + SpamShield</text>
|
||||
<rect x="60" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#06b6d430" stroke-width="1.5"/>
|
||||
<circle cx="210" cy="340" r="40" fill="#06b6d422" stroke="#06b6d4" stroke-width="2"/>
|
||||
<text x="210" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#06b6d4" text-anchor="middle">VoicePrint</text>
|
||||
<text x="210" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">AI Voice Clone</text>
|
||||
<text x="210" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Detection</text>
|
||||
<text x="210" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">Real-time detection</text>
|
||||
<text x="210" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">of synthetic voices</text>
|
||||
<text x="210" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">with 99.7% accuracy</text>
|
||||
<rect x="390" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#3b82f630" stroke-width="1.5"/>
|
||||
<circle cx="540" cy="340" r="40" fill="#3b82f622" stroke="#3b82f6" stroke-width="2"/>
|
||||
<text x="540" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#3b82f6" text-anchor="middle">DarkWatch</text>
|
||||
<text x="540" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Dark Web</text>
|
||||
<text x="540" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Monitoring</text>
|
||||
<text x="540" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">24/7 scanning of</text>
|
||||
<text x="540" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">150+ marketplaces</text>
|
||||
<text x="540" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">for your data</text>
|
||||
<rect x="720" y="280" width="300" height="400" rx="16" fill="#1a2332" stroke="#22c55e30" stroke-width="1.5"/>
|
||||
<circle cx="870" cy="340" r="40" fill="#22c55e22" stroke="#22c55e" stroke-width="2"/>
|
||||
<text x="870" y="345" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#22c55e" text-anchor="middle">SpamShield</text>
|
||||
<text x="870" y="400" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Spam Call &</text>
|
||||
<text x="870" y="432" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Text Blocking</text>
|
||||
<text x="870" y="460" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">AI-powered filtering</text>
|
||||
<text x="870" y="485" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">of spam calls</text>
|
||||
<text x="870" y="510" font-family="system-ui, sans-serif" font-size="14" fill="#94a3b8" text-anchor="middle">and text messages</text>
|
||||
<rect x="405" y="760" width="270" height="52" rx="26" fill="#3b82f6"/>
|
||||
<text x="540" y="793" font-family="system-ui, sans-serif" font-size="18" font-weight="600" fill="#f1f5f9" text-anchor="middle">Join the Waitlist</text>
|
||||
|
||||
<text x="540" y="870" font-family="system-ui, sans-serif" font-size="15" fill="#64748b" text-anchor="middle">Three critical protections, one powerful platform</text>
|
||||
<text x="540" y="900" font-family="system-ui, sans-serif" font-size="14" fill="#64748b" text-anchor="middle">Start free. Launching soon.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 124 KiB |
@@ -1,60 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="628" viewBox="0 0 1200 628">
|
||||
<defs>
|
||||
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#3b82f630"/>
|
||||
<stop offset="100%" stop-color="#3b82f600"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="60%" stop-color="#0d1a30"/>
|
||||
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="628" fill="url(#bgD)"/>
|
||||
<rect width="1200" height="5" fill="url(#brandBar)"/>
|
||||
|
||||
<!-- Digital shield overlay -->
|
||||
<circle cx="600" cy="314" r="238.64000000000001" fill="url(#shieldGlow)"/>
|
||||
<g transform="translate(600, 284)">
|
||||
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Family figures (simplified) -->
|
||||
|
||||
<!-- Grandparent L -->
|
||||
<circle cx="490" cy="319" r="22" fill="#33415580"/>
|
||||
<rect x="475" y="344" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
<!-- Parent L -->
|
||||
<circle cx="550" cy="304" r="25" fill="#47556980"/>
|
||||
<rect x="532" y="332" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Child -->
|
||||
<circle cx="615" cy="314" r="18" fill="#64748b80"/>
|
||||
<rect x="602" y="334" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||
|
||||
<!-- Parent R -->
|
||||
<circle cx="680" cy="304" r="25" fill="#47556980"/>
|
||||
<rect x="662" y="332" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Grandparent R -->
|
||||
<circle cx="740" cy="319" r="22" fill="#33415580"/>
|
||||
<rect x="725" y="344" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
|
||||
<text x="600" y="468" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||
<text x="600" y="513" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||
<text x="600" y="543" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||
|
||||
<rect x="485" y="568" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||
<text x="600" y="595" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 130 KiB |
@@ -1,60 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080" viewBox="0 0 1080 1080">
|
||||
<defs>
|
||||
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#3b82f630"/>
|
||||
<stop offset="100%" stop-color="#3b82f600"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="60%" stop-color="#0d1a30"/>
|
||||
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="1080" fill="url(#bgD)"/>
|
||||
<rect width="1080" height="5" fill="url(#brandBar)"/>
|
||||
|
||||
<!-- Digital shield overlay -->
|
||||
<circle cx="540" cy="540" r="410.4" fill="url(#shieldGlow)"/>
|
||||
<g transform="translate(540, 510)">
|
||||
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Family figures (simplified) -->
|
||||
|
||||
<!-- Grandparent L -->
|
||||
<circle cx="430" cy="545" r="22" fill="#33415580"/>
|
||||
<rect x="415" y="570" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
<!-- Parent L -->
|
||||
<circle cx="490" cy="530" r="25" fill="#47556980"/>
|
||||
<rect x="472" y="558" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Child -->
|
||||
<circle cx="555" cy="540" r="18" fill="#64748b80"/>
|
||||
<rect x="542" y="560" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||
|
||||
<!-- Parent R -->
|
||||
<circle cx="620" cy="530" r="25" fill="#47556980"/>
|
||||
<rect x="602" y="558" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Grandparent R -->
|
||||
<circle cx="680" cy="545" r="22" fill="#33415580"/>
|
||||
<rect x="665" y="570" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
|
||||
<text x="540" y="920" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||
<text x="540" y="965" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||
<text x="540" y="995" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||
|
||||
<rect x="425" y="1020" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||
<text x="540" y="1047" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,60 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1350" viewBox="0 0 1080 1350">
|
||||
<defs>
|
||||
<radialGradient id="shieldGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#3b82f630"/>
|
||||
<stop offset="100%" stop-color="#3b82f600"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0a0f1e"/>
|
||||
<stop offset="60%" stop-color="#0d1a30"/>
|
||||
<stop offset="100%" stop-color="#0a0f1e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brandBar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGradD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="1350" fill="url(#bgD)"/>
|
||||
<rect width="1080" height="5" fill="url(#brandBar)"/>
|
||||
|
||||
<!-- Digital shield overlay -->
|
||||
<circle cx="540" cy="675" r="410.4" fill="url(#shieldGlow)"/>
|
||||
<g transform="translate(540, 645)">
|
||||
<path d="M-60,-55 L60,-55 L65,15 Q65,55 35,75 L0,90 L-35,75 Q-65,55 -65,15 Z" fill="none" stroke="url(#shieldGradD)" stroke-width="3" opacity="0.8"/>
|
||||
<path d="M-25,-5 L0,25 L30,-15" fill="none" stroke="#22c55e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Family figures (simplified) -->
|
||||
|
||||
<!-- Grandparent L -->
|
||||
<circle cx="430" cy="680" r="22" fill="#33415580"/>
|
||||
<rect x="415" y="705" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
<!-- Parent L -->
|
||||
<circle cx="490" cy="665" r="25" fill="#47556980"/>
|
||||
<rect x="472" y="693" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Child -->
|
||||
<circle cx="555" cy="675" r="18" fill="#64748b80"/>
|
||||
<rect x="542" y="695" width="26" height="40" rx="8" fill="#64748b60"/>
|
||||
|
||||
<!-- Parent R -->
|
||||
<circle cx="620" cy="665" r="25" fill="#47556980"/>
|
||||
<rect x="602" y="693" width="36" height="65" rx="10" fill="#47556960"/>
|
||||
|
||||
<!-- Grandparent R -->
|
||||
<circle cx="680" cy="680" r="22" fill="#33415580"/>
|
||||
<rect x="665" y="705" width="30" height="50" rx="8" fill="#33415560"/>
|
||||
|
||||
|
||||
<text x="540" y="1190" font-family="system-ui, sans-serif" font-size="32" font-weight="700" fill="#f1f5f9" text-anchor="middle">Protect Your Whole Family</text>
|
||||
<text x="540" y="1235" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">AI voice clone detection + dark web monitoring + spam blocking</text>
|
||||
<text x="540" y="1265" font-family="system-ui, sans-serif" font-size="17" fill="#94a3b8" text-anchor="middle">for up to unlimited family members on Premium</text>
|
||||
|
||||
<rect x="425" y="1290" width="230" height="46" rx="23" fill="#3b82f6"/>
|
||||
<text x="540" y="1317" font-family="system-ui, sans-serif" font-size="17" font-weight="600" fill="#f1f5f9" text-anchor="middle">Protect My Family</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
151
design-tokens/colors.json
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Kordant brand color tokens — single source of truth for web, iOS, Android",
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-26"
|
||||
},
|
||||
"brand": {
|
||||
"primary": {
|
||||
"value": "#4F46E5",
|
||||
"description": "Main brand color — indigo"
|
||||
},
|
||||
"primaryLight": {
|
||||
"value": "#818CF8",
|
||||
"description": "Lighter variant for accents and gradients"
|
||||
},
|
||||
"primaryDark": {
|
||||
"value": "#4338CA",
|
||||
"description": "Darker variant for hover states and depth"
|
||||
},
|
||||
"accent": {
|
||||
"value": "#06B6D4",
|
||||
"description": "Secondary brand color — cyan"
|
||||
},
|
||||
"accentLight": {
|
||||
"value": "#67E8F9",
|
||||
"description": "Lighter accent variant"
|
||||
},
|
||||
"accentDark": {
|
||||
"value": "#0891B2",
|
||||
"description": "Darker accent variant"
|
||||
}
|
||||
},
|
||||
"semantic": {
|
||||
"success": {
|
||||
"value": "#06B6D4",
|
||||
"description": "Success state — cyan (on-brand)"
|
||||
},
|
||||
"warning": {
|
||||
"value": "#F59E0B",
|
||||
"description": "Warning state — amber"
|
||||
},
|
||||
"error": {
|
||||
"value": "#EF4444",
|
||||
"description": "Error state — red"
|
||||
},
|
||||
"info": {
|
||||
"value": "#4F46E5",
|
||||
"description": "Informational — brand primary"
|
||||
},
|
||||
"successBg": {
|
||||
"light": "#ECFEFF",
|
||||
"dark": "#0C4A6E",
|
||||
"description": "Success background tint"
|
||||
},
|
||||
"warningBg": {
|
||||
"light": "#FFFBEB",
|
||||
"dark": "#78350F",
|
||||
"description": "Warning background tint"
|
||||
},
|
||||
"errorBg": {
|
||||
"light": "#FEF2F2",
|
||||
"dark": "#7F1D1D",
|
||||
"description": "Error background tint"
|
||||
},
|
||||
"infoBg": {
|
||||
"light": "#EEF2FF",
|
||||
"dark": "#1E1B4B",
|
||||
"description": "Info background tint"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"bg": {
|
||||
"light": "#FAFBFC",
|
||||
"dark": "#111827",
|
||||
"description": "Primary background"
|
||||
},
|
||||
"bgSecondary": {
|
||||
"light": "#F3F4F6",
|
||||
"dark": "#1F2937",
|
||||
"description": "Secondary background (cards, sections)"
|
||||
},
|
||||
"bgTertiary": {
|
||||
"light": "#E5E7EB",
|
||||
"dark": "#374151",
|
||||
"description": "Tertiary background (inputs, disabled)"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"textPrimary": {
|
||||
"light": "#111827",
|
||||
"dark": "#F9FAFB",
|
||||
"description": "Primary text — headings, body"
|
||||
},
|
||||
"textSecondary": {
|
||||
"light": "#6B7280",
|
||||
"dark": "#D1D5DB",
|
||||
"description": "Secondary text — captions, metadata"
|
||||
},
|
||||
"textTertiary": {
|
||||
"light": "#9CA3AF",
|
||||
"dark": "#9CA3AF",
|
||||
"description": "Tertiary text — placeholders, disabled"
|
||||
}
|
||||
},
|
||||
"border": {
|
||||
"border": {
|
||||
"light": "#E5E7EB",
|
||||
"dark": "#374151",
|
||||
"description": "Default border"
|
||||
},
|
||||
"borderDark": {
|
||||
"light": "#D1D5DB",
|
||||
"dark": "#4B5563",
|
||||
"description": "Emphasized border"
|
||||
}
|
||||
},
|
||||
"glass": {
|
||||
"glass": {
|
||||
"light": "rgba(255, 255, 255, 0.8)",
|
||||
"dark": "rgba(17, 24, 39, 0.8)",
|
||||
"description": "Glass morphism background (light)"
|
||||
},
|
||||
"glassDark": {
|
||||
"light": "rgba(17, 24, 39, 0.8)",
|
||||
"dark": "rgba(17, 24, 39, 0.9)",
|
||||
"description": "Glass morphism background (dark)"
|
||||
}
|
||||
},
|
||||
"gradient": {
|
||||
"cardStart": {
|
||||
"light": "#FFFFFF",
|
||||
"dark": "#1F2937",
|
||||
"description": "Card gradient start"
|
||||
},
|
||||
"cardEnd": {
|
||||
"light": "#F3F4F6",
|
||||
"dark": "#0B1120",
|
||||
"description": "Card gradient end"
|
||||
}
|
||||
},
|
||||
"dotGrid": {
|
||||
"light": "#E5E7EB",
|
||||
"dark": "#374151",
|
||||
"description": "Background dot grid color"
|
||||
},
|
||||
"focusRing": {
|
||||
"light": "#4F46E5",
|
||||
"dark": "#818CF8",
|
||||
"description": "Focus ring outline color"
|
||||
}
|
||||
}
|
||||
33
design-tokens/radius.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Kordant border radius scale",
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-26"
|
||||
},
|
||||
"scale": {
|
||||
"none": {
|
||||
"value": "0px",
|
||||
"description": "No rounding"
|
||||
},
|
||||
"sm": {
|
||||
"value": "4px",
|
||||
"description": "Inputs, chips, badges"
|
||||
},
|
||||
"md": {
|
||||
"value": "8px",
|
||||
"description": "Cards, buttons, modals"
|
||||
},
|
||||
"lg": {
|
||||
"value": "12px",
|
||||
"description": "Large cards, panels"
|
||||
},
|
||||
"xl": {
|
||||
"value": "16px",
|
||||
"description": "Hero cards, featured sections"
|
||||
},
|
||||
"full": {
|
||||
"value": "9999px",
|
||||
"description": "Pills, avatars, badges"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
design-tokens/shadows.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Kordant shadow definitions",
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-26"
|
||||
},
|
||||
"scale": {
|
||||
"sm": {
|
||||
"x": "0",
|
||||
"y": "1",
|
||||
"blur": "2",
|
||||
"spread": "0",
|
||||
"color": "rgba(0, 0, 0, 0.05)",
|
||||
"description": "Subtle elevation — inputs, chips"
|
||||
},
|
||||
"md": {
|
||||
"x": "0",
|
||||
"y": "4",
|
||||
"blur": "6",
|
||||
"spread": "-1",
|
||||
"color": "rgba(0, 0, 0, 0.1)",
|
||||
"description": "Card elevation — default cards"
|
||||
},
|
||||
"lg": {
|
||||
"x": "0",
|
||||
"y": "10",
|
||||
"blur": "15",
|
||||
"spread": "-3",
|
||||
"color": "rgba(0, 0, 0, 0.1)",
|
||||
"description": "Modal elevation — dropdowns, menus"
|
||||
},
|
||||
"xl": {
|
||||
"x": "0",
|
||||
"y": "20",
|
||||
"blur": "25",
|
||||
"spread": "-5",
|
||||
"color": "rgba(0, 0, 0, 0.15)",
|
||||
"description": "Dialog elevation — modals, tooltips"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
design-tokens/spacing.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Kordant spacing scale — 4px base grid",
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-26"
|
||||
},
|
||||
"scale": {
|
||||
"0": {
|
||||
"value": "0px",
|
||||
"description": "No spacing"
|
||||
},
|
||||
"xs": {
|
||||
"value": "4px",
|
||||
"description": "Tightest spacing — within components"
|
||||
},
|
||||
"sm": {
|
||||
"value": "8px",
|
||||
"description": "Small gaps — icon to text, tight lists"
|
||||
},
|
||||
"md": {
|
||||
"value": "16px",
|
||||
"description": "Default spacing — card padding, form fields"
|
||||
},
|
||||
"lg": {
|
||||
"value": "24px",
|
||||
"description": "Section spacing — between cards"
|
||||
},
|
||||
"xl": {
|
||||
"value": "32px",
|
||||
"description": "Large spacing — between sections"
|
||||
},
|
||||
"xxl": {
|
||||
"value": "48px",
|
||||
"description": "Page-level spacing"
|
||||
},
|
||||
"xxxl": {
|
||||
"value": "64px",
|
||||
"description": "Hero spacing, full section gaps"
|
||||
}
|
||||
}
|
||||
}
|
||||
66
design-tokens/typography.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Kordant typography scale — Inter font family",
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-05-26"
|
||||
},
|
||||
"fontFamily": {
|
||||
"value": "Inter",
|
||||
"fallback": "-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
|
||||
},
|
||||
"scale": {
|
||||
"caption": {
|
||||
"size": "12px",
|
||||
"lineHeight": "16px",
|
||||
"description": "Fine print, captions, labels"
|
||||
},
|
||||
"body": {
|
||||
"size": "16px",
|
||||
"lineHeight": "24px",
|
||||
"description": "Default body text"
|
||||
},
|
||||
"bodyLarge": {
|
||||
"size": "18px",
|
||||
"lineHeight": "28px",
|
||||
"description": "Emphasized body text"
|
||||
},
|
||||
"headline": {
|
||||
"size": "20px",
|
||||
"lineHeight": "28px",
|
||||
"description": "Section headings, card titles"
|
||||
},
|
||||
"title": {
|
||||
"size": "24px",
|
||||
"lineHeight": "32px",
|
||||
"description": "Page titles"
|
||||
},
|
||||
"largeTitle": {
|
||||
"size": "32px",
|
||||
"lineHeight": "40px",
|
||||
"description": "Hero headlines"
|
||||
},
|
||||
"display": {
|
||||
"size": "48px",
|
||||
"lineHeight": "56px",
|
||||
"description": "Landing page hero display text"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"regular": {
|
||||
"value": 400,
|
||||
"description": "Body text, default"
|
||||
},
|
||||
"medium": {
|
||||
"value": 500,
|
||||
"description": "Emphasis in body, labels"
|
||||
},
|
||||
"semibold": {
|
||||
"value": 600,
|
||||
"description": "Headings, buttons"
|
||||
},
|
||||
"bold": {
|
||||
"value": 700,
|
||||
"description": "Display text, hero headlines"
|
||||
}
|
||||
}
|
||||
}
|
||||
204
docs/BRAND_GUIDELINES.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Kordant Brand Guidelines
|
||||
|
||||
> We protect you. We're smart about it. We explain things clearly.
|
||||
|
||||
This document defines the Kordant visual identity. All platform code (web, iOS, Android) references the single source of truth in `design-tokens/`.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Brand Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `primary` | `#4F46E5` | Primary actions, links, active states, logo |
|
||||
| `primaryLight` | `#818CF8` | Gradients, hover states, secondary emphasis |
|
||||
| `primaryDark` | `#4338CA` | Pressed states, depth, navigation active |
|
||||
| `accent` | `#06B6D4` | Secondary CTAs, success states, data viz |
|
||||
| `accentLight` | `#67E8F9` | Accent highlights, subtle backgrounds |
|
||||
| `accentDark` | `#0891B2` | Accent hover/pressed states |
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `success` | `#06B6D4` | Completed actions, secure status, on-brand green |
|
||||
| `warning` | `#F59E0B` | Pending actions, caution states, review needed |
|
||||
| `error` | `#EF4444` | Failed actions, threats detected, destructive |
|
||||
| `info` | `#4F46E5` | Neutral information, tooltips, help text |
|
||||
|
||||
### Accessibility
|
||||
|
||||
All color combinations must meet **WCAG AA** contrast requirements:
|
||||
- Normal text: 4.5:1 minimum
|
||||
- Large text (18px+ bold): 3:1 minimum
|
||||
|
||||
Use `primary` on white or `primaryLight` on dark backgrounds for links and interactive elements.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
|
||||
**Inter** — primary typeface across all platforms.
|
||||
|
||||
```
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif
|
||||
```
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Name | Size | Line Height | Weight | Usage |
|
||||
|---|---|---|---|---|
|
||||
| `caption` | 12px | 16px | 400 | Metadata, labels, fine print |
|
||||
| `body` | 16px | 24px | 400 | Default body text |
|
||||
| `bodyLarge` | 18px | 28px | 400 | Emphasized body, quotes |
|
||||
| `headline` | 20px | 28px | 600 | Card titles, section headers |
|
||||
| `title` | 24px | 32px | 600 | Page titles |
|
||||
| `largeTitle` | 32px | 40px | 700 | Hero headlines |
|
||||
| `display` | 48px | 56px | 700 | Landing page hero |
|
||||
|
||||
### Do's and Don'ts
|
||||
|
||||
- ✅ Use `semibold` (600) for headings
|
||||
- ✅ Use `regular` (400) for body text
|
||||
- ✅ Keep line lengths between 45-75 characters
|
||||
- ❌ Don't use more than 2 font sizes per screen
|
||||
- ❌ Don't use all-caps for body text
|
||||
- ❌ Don't use italic weight — use secondary text color instead
|
||||
|
||||
---
|
||||
|
||||
## Spacing
|
||||
|
||||
Based on a **4px grid**. All spacing values are multiples of 4.
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `xs` | 4px | Within components (icon to text) |
|
||||
| `sm` | 8px | Tight gaps, list items |
|
||||
| `md` | 16px | Card padding, form fields |
|
||||
| `lg` | 24px | Between cards, section padding |
|
||||
| `xl` | 32px | Between sections |
|
||||
| `xxl` | 48px | Page-level spacing |
|
||||
| `xxxl` | 64px | Hero sections, full gaps |
|
||||
|
||||
### Do's and Don'ts
|
||||
|
||||
- ✅ Always use spacing tokens, never arbitrary values
|
||||
- ✅ Use `md` as default card padding
|
||||
- ✅ Use `lg` between related content groups
|
||||
- ❌ Don't mix spacing tokens (e.g., `12px` is not in the scale)
|
||||
- ❌ Don't use `xxxl` inside cards
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Style
|
||||
|
||||
- **Outlined** icons with 1.5px or 2px stroke
|
||||
- 24×24px grid
|
||||
- Rounded stroke caps and joins
|
||||
- Consistent corner radius (2px)
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```
|
||||
icon-[category]-[name].svg
|
||||
```
|
||||
|
||||
Examples: `icon-nav-home.svg`, `icon-service-shield.svg`, `icon-action-bell.svg`
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Prefix | Examples |
|
||||
|---|---|---|
|
||||
| Navigation | `nav-` | home, dashboard, settings |
|
||||
| Services | `service-` | shield, microphone, phone, home, lock |
|
||||
| Actions | `action-` | bell, search, share, download |
|
||||
| Status | `status-` | check, alert, warning, error |
|
||||
|
||||
### Do's and Don'ts
|
||||
|
||||
- ✅ Use consistent stroke width (1.5px or 2px)
|
||||
- ✅ Design on 24×24px grid with 2px safe zone
|
||||
- ✅ Export as SVG for web, PNG at 1x/2x/3x for mobile
|
||||
- ❌ Don't mix filled and outlined styles
|
||||
- ❌ Don't use colored icons unless semantic (success/error)
|
||||
- ❌ Don't use icons larger than 48px without design review
|
||||
|
||||
---
|
||||
|
||||
## Shadows and Elevation
|
||||
|
||||
| Token | CSS | Usage |
|
||||
|---|---|---|
|
||||
| `sm` | `0 1px 2px 0 rgba(0,0,0,0.05)` | Inputs, chips, inline elements |
|
||||
| `md` | `0 4px 6px -1px rgba(0,0,0,0.1)` | Cards, default elevation |
|
||||
| `lg` | `0 10px 15px -3px rgba(0,0,0,0.1)` | Dropdowns, menus |
|
||||
| `xl` | `0 20px 25px -5px rgba(0,0,0,0.15)` | Modals, dialogs |
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `sm` | 4px | Inputs, chips, badges |
|
||||
| `md` | 8px | Cards, buttons, modals |
|
||||
| `lg` | 12px | Large cards, panels |
|
||||
| `xl` | 16px | Hero cards, featured sections |
|
||||
| `full` | 9999px | Pills, avatars, badges |
|
||||
|
||||
---
|
||||
|
||||
## Voice and Tone
|
||||
|
||||
### Principles
|
||||
|
||||
1. **Security-focused** — We make users feel safe, not scared
|
||||
2. **Empowering** — Clear actions, not jargon
|
||||
3. **Clear** — Simple language, no ambiguity
|
||||
4. **Trustworthy** — Accurate information, honest about limitations
|
||||
|
||||
### Examples
|
||||
|
||||
| Situation | ✅ Do | ❌ Don't |
|
||||
|---|---|---|
|
||||
| Threat detected | "We found your email in a breach. Here's what to do." | "CRITICAL: YOUR DATA IS COMPROMISED!" |
|
||||
| All clear | "Everything looks good. No threats found." | "No issues detected." |
|
||||
| Subscription | "Protect your family for $9.99/month" | "Purchase enterprise-grade monitoring" |
|
||||
| Onboarding | "Let's set up your protection in 2 minutes" | "Configure your security parameters" |
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### Web (SolidStart + Tailwind)
|
||||
- Colors are CSS custom properties in `app.css`
|
||||
- Generated tokens at `web/src/theme/tokens.ts`
|
||||
- Use Tailwind utility classes: `bg-bg`, `text-text-primary`, `rounded-md`
|
||||
|
||||
### iOS (SwiftUI)
|
||||
- Colors in `iOS/Kordant/Theme/Color+Kordant.swift`
|
||||
- Generated tokens at `iOS/Kordant/Theme/GeneratedTokens.swift`
|
||||
- Use `Color.brandPrimary`, `Color.textPrimary`
|
||||
|
||||
### Android (Jetpack Compose)
|
||||
- Colors in `android/.../res/values/colors.xml`
|
||||
- Generated tokens at `android/.../res/values/generated_tokens.xml`
|
||||
- Use `MaterialTheme.colors.brandPrimary`
|
||||
|
||||
---
|
||||
|
||||
## Token Workflow
|
||||
|
||||
1. **Design** updates `design-tokens/*.json`
|
||||
2. **Run** `node scripts/generate-tokens.mjs`
|
||||
3. **Commit** both JSON and generated files together
|
||||
4. **CI** verifies token drift on every PR
|
||||
|
||||
Never edit generated files manually. Always update the JSON source.
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Example: Real-Time Call Analysis
|
||||
* Demonstrates how to use the RealTimeCallAnalysisServer
|
||||
*/
|
||||
|
||||
import { RealTimeCallAnalysisServer } from '../src/lib/call-analysis/real-time-call-server';
|
||||
|
||||
async function example() {
|
||||
// Create and start the server
|
||||
const server = new RealTimeCallAnalysisServer({
|
||||
port: 8089,
|
||||
enableEchoCancellation: true,
|
||||
enableNoiseSuppression: true,
|
||||
enableAutoGainControl: true,
|
||||
analysisConfig: {
|
||||
sentimentWindowMs: 5000,
|
||||
interruptThresholdMs: 200,
|
||||
overlapThresholdMs: 300,
|
||||
pauseThresholdMs: 2000,
|
||||
volumeSpikeThreshold: 0.8,
|
||||
anomalySensitivity: 'medium',
|
||||
enableSpeakerDiarization: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for events
|
||||
server.on('client:connected', ({ clientId }) => {
|
||||
console.log(`Client connected: ${clientId}`);
|
||||
});
|
||||
|
||||
server.on('client:disconnected', ({ clientId }) => {
|
||||
console.log(`Client disconnected: ${clientId}`);
|
||||
});
|
||||
|
||||
server.on('analysis:alert', ({ clientId, alert }) => {
|
||||
console.log(`Alert from ${clientId}: ${alert.message} (${alert.severity})`);
|
||||
});
|
||||
|
||||
server.on('analysis:result', ({ clientId, status }) => {
|
||||
console.log(`Analysis status for ${clientId}: ${status}`);
|
||||
});
|
||||
|
||||
server.on('analysis:error', ({ clientId, error }) => {
|
||||
console.error(`Error for ${clientId}:`, error);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await server.start();
|
||||
console.log('Server started, waiting for clients...');
|
||||
|
||||
// Example: Client connection simulation
|
||||
const WebSocket = require('ws');
|
||||
const client = new WebSocket('ws://localhost:8089?clientId=test-client');
|
||||
|
||||
client.on('open', () => {
|
||||
console.log('Client connected');
|
||||
|
||||
// Start audio capture
|
||||
client.send(JSON.stringify({ type: 'start' }));
|
||||
});
|
||||
|
||||
client.on('message', (data: Buffer) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log('Received:', message.type, message);
|
||||
|
||||
if (message.type === 'alert' || message.type === 'anomaly') {
|
||||
console.log(` - ${message.alertType}: ${message.message}`);
|
||||
}
|
||||
|
||||
if (message.type === 'analysis') {
|
||||
console.log(` - MOS: ${message.callQuality.mosScore}`);
|
||||
console.log(` - Sentiment: ${message.sentiment.sentiment}`);
|
||||
console.log(` - Summary: ${message.summary}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop after 60 seconds
|
||||
setTimeout(async () => {
|
||||
console.log('Stopping server...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
// Run example if called directly
|
||||
if (require.main === module) {
|
||||
example().catch(console.error);
|
||||
}
|
||||
|
||||
export default example;
|
||||
@@ -322,8 +322,8 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -380,8 +380,8 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -400,17 +400,12 @@
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||
{
|
||||
CFBundleURLSchemes = (kordant);
|
||||
CFBundleURLName = "com.mikefreno.Kordant";
|
||||
},
|
||||
);
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Kordant needs microphone access to enroll your voice for clone detection.";
|
||||
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.mikefreno.Kordant\";\n CFBundleURLSchemes = (\n kordant\n );\n}";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Kordant uses the camera to scan documents and verify your identity.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Use Face ID to securely access your Kordant account.";
|
||||
INFOPLIST_KEY_UIBackgroundModes = (remote-notification);
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Kordant needs microphone access to enroll your voice for clone detection.";
|
||||
INFOPLIST_KEY_UIBackgroundModes = "remote-notification";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -440,17 +435,12 @@
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||
{
|
||||
CFBundleURLSchemes = (kordant);
|
||||
CFBundleURLName = "com.mikefreno.Kordant";
|
||||
},
|
||||
);
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Kordant needs microphone access to enroll your voice for clone detection.";
|
||||
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleURLName = \"com.mikefreno.Kordant\";\n CFBundleURLSchemes = (\n kordant\n );\n}";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Kordant uses the camera to scan documents and verify your identity.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Use Face ID to securely access your Kordant account.";
|
||||
INFOPLIST_KEY_UIBackgroundModes = (remote-notification);
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Kordant needs microphone access to enroll your voice for clone detection.";
|
||||
INFOPLIST_KEY_UIBackgroundModes = "remote-notification";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# FRE-4499 Implementation Plan: SpamShield Real-Time Interception
|
||||
|
||||
## Current State
|
||||
|
||||
### ✅ Implemented
|
||||
- [x] Basic `SpamShieldService` class structure
|
||||
- [x] Hiya and Truecaller API integration (with circuit breakers)
|
||||
- [x] E.164 phone number validation
|
||||
- [x] Basic reputation checking
|
||||
- [x] Circuit breaker pattern for external APIs
|
||||
- [x] **NEW**: Carrier API integration (Twilio/Plivo)
|
||||
- [x] **NEW**: Carrier factory for carrier management
|
||||
- [x] **NEW**: Decision engine with multi-layer scoring
|
||||
- [x] **NEW**: Rule engine for pattern matching
|
||||
- [x] **NEW**: WebSocket alert server for real-time notifications
|
||||
- [x] **NEW**: Combined call/SMS interception methods
|
||||
|
||||
### ❌ Missing
|
||||
- [ ] Integration tests for carrier APIs
|
||||
- [ ] Load testing for decision latency
|
||||
- [ ] Rule management API endpoints
|
||||
- [ ] User feedback loop UI integration
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### Phase 1: Core Interception Engine ✅ COMPLETE
|
||||
|
||||
#### 1.1 Carrier API Integration
|
||||
**File**: `services/spamshield/src/carriers/`
|
||||
- ✅ `carrier-types.ts` - Core carrier interfaces
|
||||
- ✅ `twilio-carrier.ts` - Twilio implementation
|
||||
- ✅ `plivo-carrier.ts` - Plivo implementation
|
||||
- ✅ `carrier-factory.ts` - Carrier management factory
|
||||
- ✅ `index.ts` - Module exports
|
||||
|
||||
#### 1.2 Decision Engine
|
||||
**File**: `services/spamshield/src/engine/`
|
||||
- ✅ `decision-engine.ts` - Multi-layer scoring decision engine
|
||||
- ✅ `rule-engine.ts` - Pattern matching rule engine
|
||||
- ✅ `index.ts` - Module exports
|
||||
|
||||
#### 1.3 WebSocket Alert Server
|
||||
**File**: `services/spamshield/src/websocket/`
|
||||
- ✅ `alert-server.ts` - Real-time alert broadcasting
|
||||
- ✅ `index.ts` - Module exports
|
||||
|
||||
### Phase 2: Service Integration ✅ COMPLETE
|
||||
|
||||
**File**: `services/spamshield/src/services/spamshield.service.ts`
|
||||
- ✅ Integrated carrier factory
|
||||
- ✅ Integrated decision engine
|
||||
- ✅ Integrated WebSocket alert server
|
||||
- ✅ Added `interceptCall()` method
|
||||
- ✅ Added `interceptSms()` method
|
||||
- ✅ Added `executeCarrierAction()` method
|
||||
|
||||
### Phase 3: Testing & Validation ⏳ PENDING
|
||||
|
||||
#### 3.1 Integration Tests
|
||||
- [ ] Mock carrier API responses
|
||||
- [ ] Test decision engine with various scenarios
|
||||
- [ ] Performance: verify <200ms decision latency
|
||||
- [ ] Fallback behavior when APIs fail
|
||||
|
||||
#### 3.2 Load Testing
|
||||
- [ ] Simulate 1000 concurrent calls
|
||||
- [ ] Verify circuit breaker triggers correctly
|
||||
- [ ] Test memory usage under sustained load
|
||||
|
||||
## Implementation Order Completed
|
||||
|
||||
1. ✅ **Heartbeat 1**: Created carrier API integration (Twilio/Plivo)
|
||||
2. ✅ **Heartbeat 1**: Implemented decision engine
|
||||
3. ✅ **Heartbeat 1**: Added WebSocket alert server skeleton
|
||||
4. ✅ **Heartbeat 1**: Extended SpamShieldService with interception methods
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. **Testing Phase**: Create comprehensive integration tests
|
||||
2. **Performance Validation**: Verify decision latency <200ms
|
||||
3. **Rule Management**: Add API endpoints for rule CRUD operations
|
||||
4. **Documentation**: Add usage examples and API docs
|
||||
|
||||
## Success Criteria Status
|
||||
|
||||
| Metric | Target | Status |
|
||||
|--------|--------|--------|
|
||||
| Decision latency (P99) | <200ms | ⏳ To be validated |
|
||||
| Decision accuracy (precision) | >0.95 | ⏳ To be validated |
|
||||
| Fallback reliability | 100% | ✅ Implemented |
|
||||
| Memory footprint | <50MB per instance | ⏳ To be validated |
|
||||
| Concurrent decisions | 1000+ | ⏳ To be validated |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@kordant/db`: Database schemas (exists)
|
||||
- `libphonenumber-js`: Phone validation (already in package.json)
|
||||
- `ws`: WebSocket library (needs to be added to package.json)
|
||||
- Twilio/Plivo SDKs: For carrier integration (using direct HTTP)
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation | Status |
|
||||
|------|------------|--------|
|
||||
| Carrier API rate limits | Circuit breakers + exponential backoff | ✅ Implemented |
|
||||
| High latency decisions | Pre-compute cached reputation scores | ✅ Implemented |
|
||||
| False positives | User feedback loop + whitelist | ⏳ Partial |
|
||||
| Memory leaks in WebSocket | Connection cleanup on close | ✅ Implemented |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SpamShieldService │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Hiya │ │ Truecaller │ │ Carrier │ │
|
||||
│ │ Circuit │ │ Circuit │ │ Factory │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┴──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ Decision │ │
|
||||
│ │ Engine │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────┴─────────────────┐ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼──────┐ ┌─────▼─────┐ │
|
||||
│ │ Rule Engine │ │ Alert │ │
|
||||
│ │ │ │ Server │ │
|
||||
│ └─────────────┘ │ (WebSocket│ │
|
||||
│ └───────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created
|
||||
- `services/spamshield/src/carriers/carrier-types.ts`
|
||||
- `services/spamshield/src/carriers/twilio-carrier.ts`
|
||||
- `services/spamshield/src/carriers/plivo-carrier.ts`
|
||||
- `services/spamshield/src/carriers/carrier-factory.ts`
|
||||
- `services/spamshield/src/carriers/index.ts`
|
||||
- `services/spamshield/src/engine/decision-engine.ts`
|
||||
- `services/spamshield/src/engine/rule-engine.ts`
|
||||
- `services/spamshield/src/engine/index.ts`
|
||||
- `services/spamshield/src/websocket/alert-server.ts`
|
||||
- `services/spamshield/src/websocket/index.ts`
|
||||
|
||||
### Modified
|
||||
- `services/spamshield/src/services/spamshield.service.ts`
|
||||
- `services/spamshield/src/index.ts`
|
||||
|
||||
## Notes
|
||||
|
||||
- Decision engine uses weighted scoring: Reputation (40%), Rules (30%), Behavioral (20%), User History (10%)
|
||||
- Thresholds: BLOCK >= 0.85, FLAG >= 0.60, ALLOW < 0.60
|
||||
- All carrier actions are logged to `SpamAuditLog` for audit trail
|
||||
- WebSocket server supports client subscriptions and heartbeat
|
||||
- Fallback behavior defaults to ALLOW on errors (conservative approach)
|
||||
@@ -1,67 +0,0 @@
|
||||
# FRE-4522 - Update spamshield.config.ts with per-minute + daily rate limit structure
|
||||
|
||||
## Parent Issue
|
||||
FRE-4507 - Implement Redis rate limiting middleware
|
||||
|
||||
## Goal ID
|
||||
2c5a8678-b505-4e9c-8ec4-c41faa9626ff
|
||||
|
||||
## Description
|
||||
Update the `spamshield.config.ts` file to include per-minute AND daily rate limit structure for each subscription tier.
|
||||
|
||||
### Current State
|
||||
The current `spamshield.config.ts` only has single value rate limits:
|
||||
```typescript
|
||||
export const spamRateLimits = {
|
||||
BASIC: 100,
|
||||
PLUS: 500,
|
||||
PREMIUM: 2000,
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Required Changes
|
||||
Refactor `spamRateLimits` to include both per-minute and daily limits:
|
||||
|
||||
```typescript
|
||||
export const spamRateLimits = {
|
||||
BASIC: { perMinute: 100, perDay: 1000 },
|
||||
PLUS: { perMinute: 500, perDay: 5000 },
|
||||
PREMIUM: { perMinute: 2000, perDay: 20000 },
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Type Definition
|
||||
Add type definition for the rate limit structure:
|
||||
```typescript
|
||||
export interface TierRateLimits {
|
||||
perMinute: number;
|
||||
perDay: number;
|
||||
}
|
||||
|
||||
export type SubscriptionTierRateLimits = Record<SubscriptionTier, TierRateLimits>;
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Update `spamRateLimits` to include `perMinute` and `perDay` properties
|
||||
- [ ] Add `TierRateLimits` interface definition
|
||||
- [ ] Update `SubscriptionTierRateLimits` type
|
||||
- [ ] Ensure type safety with `as const` assertion
|
||||
- [ ] All existing imports/exports continue to work
|
||||
|
||||
## File to Modify
|
||||
`services/spamshield/src/config/spamshield.config.ts`
|
||||
|
||||
## Priority
|
||||
HIGH (Blocker for FRE-4523 - middleware depends on config structure)
|
||||
|
||||
## Status
|
||||
done
|
||||
|
||||
## Assigned To
|
||||
d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer)
|
||||
|
||||
## Dependencies
|
||||
- None (foundational config change)
|
||||
|
||||
## Notes
|
||||
This is the first child issue in the FRE-4507 implementation sequence. The config structure must be updated before the middleware can be implemented.
|
||||
@@ -1,74 +0,0 @@
|
||||
# FRE-4523 - Create spam-rate-limit.middleware.ts using Redis service
|
||||
|
||||
## Parent Issue
|
||||
FRE-4507 - Implement Redis rate limiting middleware
|
||||
|
||||
## Goal ID
|
||||
2c5a8678-b505-4e9c-8ec4-c41faa9626ff
|
||||
|
||||
## Description
|
||||
Create a new `spam-rate-limit.middleware.ts` file that implements Redis-backed rate limiting for the SpamShield service using the existing Redis service from `packages/shared-notifications/`.
|
||||
|
||||
### Requirements
|
||||
The middleware should:
|
||||
1. Use the RedisService from `@kordant/shared-notifications`
|
||||
2. Implement per-minute AND daily rate limit tracking
|
||||
3. Check rate limits before processing spam classification requests
|
||||
4. Return appropriate HTTP 429 responses when limits are exceeded
|
||||
5. Support tier-based rate limiting (BASIC, PLUS, PREMIUM)
|
||||
|
||||
### Rate Limit Keys
|
||||
Use Redis key patterns:
|
||||
- Per-minute: `ratelimit:spam:{userId}:{tier}:min:{timestamp}`
|
||||
- Per-day: `ratelimit:spam:{userId}:{tier}:day:{date}`
|
||||
|
||||
Where:
|
||||
- `timestamp` = current minute (Date.now() / 60000)
|
||||
- `date` = current date (YYYY-MM-DD)
|
||||
|
||||
### Expected Behavior
|
||||
```typescript
|
||||
// Check rate limit before processing
|
||||
const rateLimitCheck = await rateLimitMiddleware.checkLimit(userId, tier);
|
||||
|
||||
if (rateLimitCheck.exceeded) {
|
||||
// Return 429 with retry-after header
|
||||
return reply.code(429).send({
|
||||
error: 'Rate limit exceeded',
|
||||
limit: rateLimitCheck.limit,
|
||||
remaining: rateLimitCheck.remaining,
|
||||
resetAt: rateLimitCheck.resetAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Continue with spam classification
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Create `services/spamshield/src/middleware/spam-rate-limit.middleware.ts`
|
||||
- [ ] Import and use RedisService from `@kordant/shared-notifications`
|
||||
- [ ] Implement `checkLimit(userId, tier)` method returning rate limit status
|
||||
- [ ] Implement `incrementCounter(userId, tier)` method
|
||||
- [ ] Support per-minute and per-day limit tracking
|
||||
- [ ] Return proper rate limit metadata (remaining, resetAt, limit)
|
||||
- [ ] Handle Redis connection errors gracefully
|
||||
- [ ] Export middleware class and factory function
|
||||
|
||||
## File to Create
|
||||
`services/spamshield/src/middleware/spam-rate-limit.middleware.ts`
|
||||
|
||||
## Dependencies
|
||||
- FRE-4522 (spamshield.config.ts with rate limit structure)
|
||||
- `@kordant/shared-notifications` (RedisService)
|
||||
|
||||
## Priority
|
||||
HIGH (Core middleware implementation)
|
||||
|
||||
## Status
|
||||
done
|
||||
|
||||
## Assigned To
|
||||
d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer)
|
||||
|
||||
## Notes
|
||||
This middleware will be integrated into the spam classification pipeline to enforce rate limits before processing requests.
|
||||
@@ -1,134 +0,0 @@
|
||||
# FRE-4524 - Create spamshield.routes.ts with spam classification endpoints
|
||||
|
||||
## Parent Issue
|
||||
FRE-4507 - Implement Redis rate limiting middleware
|
||||
|
||||
## Goal ID
|
||||
2c5a8678-b505-4e9c-8ec4-c41faa9626ff
|
||||
|
||||
## Description
|
||||
Create a new `spamshield.routes.ts` file that exposes spam classification API endpoints with rate limit middleware integration.
|
||||
|
||||
### Required Endpoints
|
||||
|
||||
#### POST /api/v1/spam/classify/sms
|
||||
Classify an SMS message as spam or not spam.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
phoneNumber: string; // E.164 format
|
||||
message: string;
|
||||
userId: string;
|
||||
tier: 'BASIC' | 'PLUS' | 'PREMIUM';
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
features: string[];
|
||||
rateLimit: {
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit:** Applied via spam-rate-limit.middleware.ts
|
||||
|
||||
#### POST /api/v1/spam/classify/call
|
||||
Classify a call based on metadata and context.
|
||||
|
||||
**Request Body:**
|
||||
```typescript
|
||||
{
|
||||
phoneNumber: string; // E.164 format
|
||||
callMetadata: {
|
||||
duration?: number;
|
||||
timeOfDay?: string;
|
||||
frequency?: number;
|
||||
};
|
||||
userId: string;
|
||||
tier: 'BASIC' | 'PLUS' | 'PREMIUM';
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
decision: 'BLOCK' | 'FLAG' | 'ALLOW';
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
rateLimit: {
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit:** Applied via spam-rate-limit.middleware.ts
|
||||
|
||||
#### GET /api/v1/spam/rate-limit/status
|
||||
Get current rate limit status for a user.
|
||||
|
||||
**Query Parameters:**
|
||||
- `userId`: string (required)
|
||||
- `tier`: 'BASIC' | 'PLUS' | 'PREMIUM' (required)
|
||||
|
||||
**Response:**
|
||||
```typescript
|
||||
{
|
||||
userId: string;
|
||||
tier: string;
|
||||
currentLimits: {
|
||||
perMinute: {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
};
|
||||
perDay: {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Create `services/spamshield/src/routes/spamshield.routes.ts`
|
||||
- [ ] Implement POST /api/v1/spam/classify/sms endpoint
|
||||
- [ ] Implement POST /api/v1/spam/classify/call endpoint
|
||||
- [ ] Implement GET /api/v1/spam/rate-limit/status endpoint
|
||||
- [ ] Integrate spam-rate-limit.middleware.ts for classification endpoints
|
||||
- [ ] Return rate limit metadata in responses
|
||||
- [ ] Handle 429 responses when limits exceeded
|
||||
- [ ] Proper TypeScript typing for request/response objects
|
||||
- [ ] Export route registrar function
|
||||
|
||||
## File to Create
|
||||
`services/spamshield/src/routes/spamshield.routes.ts`
|
||||
|
||||
## Dependencies
|
||||
- FRE-4522 (spamshield.config.ts with rate limit structure)
|
||||
- FRE-4523 (spam-rate-limit.middleware.ts)
|
||||
- `@kordant/types` (for type definitions)
|
||||
|
||||
## Priority
|
||||
MEDIUM (Depends on middleware implementation)
|
||||
|
||||
## Status
|
||||
todo
|
||||
|
||||
## Assigned To
|
||||
d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer)
|
||||
|
||||
## Notes
|
||||
Routes should follow the existing pattern in `packages/api/src/routes/` for consistency. The spamshield service routes will be registered in the API gateway.
|
||||
@@ -1,97 +0,0 @@
|
||||
# FRE-4525 - Add rate limit tests
|
||||
|
||||
## Parent Issue
|
||||
FRE-4507 - Implement Redis rate limiting middleware
|
||||
|
||||
## Goal ID
|
||||
2c5a8678-b505-4e9c-8ec4-c41faa9626ff
|
||||
|
||||
## Description
|
||||
Add comprehensive unit and integration tests for the spam rate limiting functionality across the config, middleware, and routes.
|
||||
|
||||
### Test Coverage Requirements
|
||||
|
||||
#### 1. Config Tests (spamshield.config.test.ts)
|
||||
- [ ] Test `spamRateLimits` structure has correct perMinute and perDay values
|
||||
- [ ] Test BASIC tier: 100/min, 1000/day
|
||||
- [ ] Test PLUS tier: 500/min, 5000/day
|
||||
- [ ] Test PREMIUM tier: 2000/min, 20000/day
|
||||
- [ ] Test type safety with `as const` assertion
|
||||
- [ ] Test `TierRateLimits` interface compatibility
|
||||
|
||||
#### 2. Middleware Tests (spam-rate-limit.middleware.test.ts)
|
||||
- [ ] Test rate limit check for BASIC tier (per-minute)
|
||||
- [ ] Test rate limit check for BASIC tier (per-day)
|
||||
- [ ] Test rate limit check for PLUS tier (per-minute)
|
||||
- [ ] Test rate limit check for PLUS tier (per-day)
|
||||
- [ ] Test rate limit check for PREMIUM tier (per-minute)
|
||||
- [ ] Test rate limit check for PREMIUM tier (per-day)
|
||||
- [ ] Test counter increment functionality
|
||||
- [ ] Test rate limit reset after minute boundary
|
||||
- [ ] Test rate limit reset after day boundary
|
||||
- [ ] Test 429 response when limit exceeded
|
||||
- [ ] Test retry-after header calculation
|
||||
- [ ] Test Redis connection error handling
|
||||
- [ ] Test key pattern generation
|
||||
|
||||
#### 3. Route Tests (spamshield.routes.test.ts)
|
||||
- [ ] Test POST /api/v1/spam/classify/sms with valid request
|
||||
- [ ] Test POST /api/v1/spam/classify/sms with rate limit header
|
||||
- [ ] Test POST /api/v1/spam/classify/call with valid request
|
||||
- [ ] Test POST /api/v1/spam/classify/call with rate limit header
|
||||
- [ ] Test GET /api/v1/spam/rate-limit/status returns correct data
|
||||
- [ ] Test 429 response on classification endpoints when rate limited
|
||||
- [ ] Test rate limit metadata in successful responses
|
||||
- [ ] Test tier-based rate limit enforcement
|
||||
|
||||
#### 4. Integration Tests (spam-rate-limit.integration.test.ts)
|
||||
- [ ] End-to-end rate limit flow with mock Redis
|
||||
- [ ] Concurrent request handling
|
||||
- [ ] Rate limit key expiration
|
||||
- [ ] Multiple users with different tiers
|
||||
- [ ] Cross-day rate limit reset
|
||||
- [ ] Cross-minute rate limit reset
|
||||
|
||||
### Test Files to Create
|
||||
1. `services/spamshield/test/spamshield.config.test.ts`
|
||||
2. `services/spamshield/test/spam-rate-limit.middleware.test.ts`
|
||||
3. `services/spamshield/test/spamshield.routes.test.ts`
|
||||
4. `services/spamshield/test/spam-rate-limit.integration.test.ts`
|
||||
|
||||
### Mock Requirements
|
||||
- Mock RedisService for unit tests
|
||||
- Mock SpamShieldService for route tests
|
||||
- Use vitest for test framework (existing in project)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All config tests pass (5 tests)
|
||||
- [ ] All middleware tests pass (13 tests)
|
||||
- [ ] All route tests pass (8 tests)
|
||||
- [ ] All integration tests pass (6 tests)
|
||||
- [ ] Minimum 90% code coverage for rate limiting code
|
||||
- [ ] Tests follow existing test patterns in `services/spamshield/test/`
|
||||
- [ ] Use vitest framework with proper mocking
|
||||
|
||||
## Files to Create
|
||||
- `services/spamshield/test/spamshield.config.test.ts`
|
||||
- `services/spamshield/test/spam-rate-limit.middleware.test.ts`
|
||||
- `services/spamshield/test/spamshield.routes.test.ts`
|
||||
- `services/spamshield/test/spam-rate-limit.integration.test.ts`
|
||||
|
||||
## Dependencies
|
||||
- FRE-4522 (spamshield.config.ts)
|
||||
- FRE-4523 (spam-rate-limit.middleware.ts)
|
||||
- FRE-4524 (spamshield.routes.ts)
|
||||
- `vitest` (existing test framework)
|
||||
|
||||
## Priority
|
||||
LOW (Can be implemented in parallel with routes, but depends on middleware)
|
||||
|
||||
## Status
|
||||
todo
|
||||
|
||||
## Assigned To
|
||||
d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer)
|
||||
|
||||
## Notes
|
||||
Tests should be written to validate the complete rate limiting flow. Integration tests require a running Redis instance or mock.
|
||||
@@ -1,132 +0,0 @@
|
||||
# Spam & ID Protection Product Plan
|
||||
|
||||
## Product Name: Kordant
|
||||
|
||||
### Vision
|
||||
Protect individuals from predatory AI-driven scams through multi-layered identity protection.
|
||||
|
||||
## Target Market
|
||||
- Consumers concerned about AI voice cloning attacks
|
||||
- Families with elderly members (prime targets for voice scam)
|
||||
- Professionals managing multiple digital identities
|
||||
- High-net-worth individuals needing home title protection
|
||||
|
||||
## Product Tiers
|
||||
|
||||
### 1. Kordant Basic (Free)
|
||||
**Purpose:** Traffic driver, entry point
|
||||
|
||||
**Features:**
|
||||
- Dark web scan for phone numbers (1 scan/month)
|
||||
- Dark web scan for emails (1 scan/month)
|
||||
- Basic spam call detection
|
||||
- Spam text alerts (up to 50/month)
|
||||
- Blog access: "Free Rights & Strategies" protection guides
|
||||
|
||||
**Limitations:**
|
||||
- No voice cloning protection
|
||||
- Limited dark web coverage
|
||||
- Basic alerting only
|
||||
|
||||
---
|
||||
|
||||
### 2. Kordant Plus ($9.99/month)
|
||||
**Purpose:** Core protection for individuals
|
||||
|
||||
**Features:**
|
||||
- Everything in Basic, plus:
|
||||
- Dark web scans: Unlimited phone, email monitoring
|
||||
- Password leak detection
|
||||
- Family voice cloning attack detection (up to 3 family members)
|
||||
- AI spam call blocking (real-time)
|
||||
- AI spam text blocking (real-time)
|
||||
- Monthly protection report
|
||||
- Priority email support
|
||||
|
||||
**Target:** Tech-savvy consumers, families with elderly parents
|
||||
|
||||
---
|
||||
|
||||
### 3. Kordant Premium ($24.99/month)
|
||||
**Purpose:** Comprehensive identity protection
|
||||
|
||||
**Features:**
|
||||
- Everything in Plus, plus:
|
||||
- Dark web scans: Phone, email, passwords, SSN monitoring
|
||||
- Unlimited family voice cloning protection
|
||||
- Home title protection monitoring
|
||||
- Financial account fraud detection
|
||||
- Social media account monitoring
|
||||
- Real-time AI scam call/text blocking
|
||||
- Proactive fraud alert system
|
||||
- 24/7 phone + chat support
|
||||
- Annual protection audit
|
||||
|
||||
**Target:** High-net-worth individuals, executives, families with significant assets
|
||||
|
||||
---
|
||||
|
||||
## Go-to-Market Strategy
|
||||
|
||||
### Content Marketing (CMO Ownership)
|
||||
- **Blog Series:** "Free Rights & Strategies" - educational content on:
|
||||
- How AI voice cloning works
|
||||
- Recognizing spam calls vs. legitimate calls
|
||||
- Family protection strategies
|
||||
- Dark web monitoring explained
|
||||
- Home title fraud prevention
|
||||
|
||||
- **SEO Focus:** "spam call protection," "AI voice scam," "dark web phone scan"
|
||||
|
||||
### Technical Implementation (CTO Ownership)
|
||||
- **Voice Cloning Detection:**
|
||||
- Audio fingerprinting for family members
|
||||
- Real-time comparison during incoming calls
|
||||
- ML model for detecting synthetic voice patterns
|
||||
|
||||
- **Dark Web Scanning:**
|
||||
- Integration with dark web data sources
|
||||
- Automated monitoring for phone, email, password leaks
|
||||
- Alert system for new exposures
|
||||
|
||||
- **Spam Protection:**
|
||||
- Call screening API integration
|
||||
- SMS filtering with ML classification
|
||||
- Real-time blocking engine
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
- Free tier signups (traffic goal)
|
||||
- Free-to-paid conversion rate
|
||||
- Voice cloning detection accuracy
|
||||
- Spam call/text blocking rate
|
||||
- Dark web exposure alerts per user
|
||||
- Churn rate by tier
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### CTO Tasks:
|
||||
- [ ] Design voice cloning detection architecture
|
||||
- [ ] Specify dark web scanning integration points
|
||||
- [ ] Define spam blocking technical requirements
|
||||
- [ ] Estimate development timeline
|
||||
- [ ] Identify third-party APIs vs. build decisions
|
||||
|
||||
### CMO Tasks:
|
||||
- [ ] Develop product positioning and messaging
|
||||
- [ ] Create "Free Rights & Strategies" blog content calendar
|
||||
- [ ] Define pricing page copy and tier comparisons
|
||||
- [ ] Plan launch campaign (email, social, content)
|
||||
- [ ] Research competitive landscape
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
- Should we offer annual pricing discounts?
|
||||
- What's the ideal free tier limitation structure?
|
||||
- Do we need enterprise tier for businesses?
|
||||
- Integration partners for dark web data sources?
|
||||
- Voice cloning accuracy thresholds for alerts?
|
||||
@@ -1,448 +0,0 @@
|
||||
# Kordant Technical Architecture & Implementation Plan
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
Kordant is a multi-service SaaS platform with three core engines:
|
||||
|
||||
1. **VoicePrint** — voice cloning detection and synthetic voice analysis
|
||||
2. **DarkWatch** — dark web exposure monitoring and alerting
|
||||
3. **SpamShield** — real-time spam call/text classification and blocking
|
||||
|
||||
All three engines share a common platform layer (auth, billing, user management, notification system, API gateway).
|
||||
|
||||
---
|
||||
|
||||
## 2. High-Level Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Client Apps │
|
||||
│ (Web Dashboard · Mobile App · CLI · Browser Extension) │
|
||||
└──────────────────────┬───────────────────────────────────┘
|
||||
│ HTTPS / WSS
|
||||
┌──────────────────────▼───────────────────────────────────┐
|
||||
│ API Gateway │
|
||||
│ (Rate limiting · Auth · Routing · Logging) │
|
||||
└──┬──────────────┬──────────────┬──────────────┬──────────┘
|
||||
│ │ │ │
|
||||
┌──▼─────┐ ┌────▼─────┐ ┌────▼─────┐ ┌────▼──────────┐
|
||||
│Users/ │ │ VoicePrint│ │DarkWatch │ │ SpamShield │
|
||||
│Billing │ │ Service │ │ Service │ │ Service │
|
||||
└────────┘ └───────────┘ └──────────┘ └───────────────┘
|
||||
│ │ │ │
|
||||
┌──▼──────────────▼──────────────▼──────────────▼──────────┐
|
||||
│ Shared Infrastructure │
|
||||
│ (Message Queue · Cache · Object Store · ML Pipeline) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology | Rationale |
|
||||
|-------|-----------|-----------|
|
||||
| Language | TypeScript (Node.js) | Team velocity, shared codebase, strong ecosystem |
|
||||
| Framework | Fastify (API), Next.js (dashboard) | Performance, SSR, mature |
|
||||
| Database | PostgreSQL + Prisma | Relational data, type safety, migrations |
|
||||
| Cache | Redis | Session, rate limits, real-time alert dedup |
|
||||
| Queue | BullMQ (Redis-backed) | Dark web scan jobs, voice analysis jobs |
|
||||
| Object Store | S3 / MinIO | Audio samples, reports, scan results |
|
||||
| ML Runtime | Python microservice (FastAPI) | Voice analysis models, spam classification |
|
||||
| Container | Docker + Docker Compose (dev), K8s (prod) | Portability, scaling |
|
||||
| Infra | Terraform + AWS (ECS/Fargate or EKS) | Cloud-native, auto-scaling |
|
||||
| CI/CD | GitHub Actions | Automated build, test, deploy |
|
||||
|
||||
---
|
||||
|
||||
## 3. VoicePrint Service — Voice Cloning Detection
|
||||
|
||||
### 3.1 Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Audio In │────▶│ Preprocessor │────▶│ ML Classifier │
|
||||
│ (upload/ │ │ (VAD, NR, │ │ (Synthetic vs │
|
||||
│ live call)│ │ normalization)│ │ Natural voice) │
|
||||
└──────────────┘ └──────────────┘ └────────┬────────┘
|
||||
│
|
||||
┌──────────────┐ ┌──────────────┐ ┌────────▼────────┐
|
||||
│ Alert/ │◀────│ Result │◀────│ Voice │
|
||||
│ Dashboard │ │ Formatter │ │ Fingerprint │
|
||||
└──────────────┘ └──────────────┘ │ Matcher │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Components
|
||||
|
||||
**Audio Preprocessor (Python)**
|
||||
- Voice Activity Detection (VAD): Silero VAD
|
||||
- Noise reduction: WebRTC VAD + RNNoise
|
||||
- Sample rate normalization to 16kHz mono
|
||||
- Chunking for real-time streaming analysis
|
||||
|
||||
**ML Classifier — Synthetic Voice Detection**
|
||||
- Primary model: Fine-tuned **ECAPA-TDNN** (state-of-the-art speaker embedding)
|
||||
- Secondary: **WaveNet-based** anomaly detector for artifacts in synthetic audio
|
||||
- Training data: ASVspoof 2019/2021 corpus + internal synthetic voice samples
|
||||
- Output: confidence score (0-1) that audio is synthetic/cloned
|
||||
- Threshold: configurable per tier (Plus: 0.7, Premium: 0.6)
|
||||
|
||||
**Voice Fingerprint Matcher**
|
||||
- Enrollments: store speaker embeddings for registered family members
|
||||
- Cosine similarity matching against enrollment vault
|
||||
- New voice detection: "unrecognized speaker" alerts for incoming calls
|
||||
- Storage: FAISS index for fast approximate nearest neighbor search
|
||||
|
||||
**Real-Time Call Analysis (Premium)**
|
||||
- WebRTC-based audio stream interception
|
||||
- Sliding window analysis (5-second chunks, 1-second overlap)
|
||||
- WebSocket push for real-time alerts to client
|
||||
|
||||
### 3.3 Build vs Buy
|
||||
|
||||
| Component | Decision | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| Synthetic voice detection | **Build** (fine-tune open models) | Core IP, differentiator, ASVspoof models are open |
|
||||
| Voice fingerprinting | **Build** (ECAPA-TDNN + FAISS) | Well-understood, low cost at scale |
|
||||
| Real-time audio pipeline | **Build** (WebRTC + Python) | Tight integration with blocking engine |
|
||||
| Alternative API | **Sonix** or **Rev.ai** (fallback) | Use as secondary validation if needed |
|
||||
|
||||
### 3.4 API Surface
|
||||
|
||||
```
|
||||
POST /api/v1/voiceprint/enroll — Enroll a voice profile
|
||||
GET /api/v1/voiceprint/enrollments — List enrolled profiles
|
||||
DELETE /api/v1/voiceprint/enrollments/:id — Remove enrollment
|
||||
POST /api/v1/voiceprint/analyze — Upload audio for analysis
|
||||
WS /api/v1/voiceprint/stream — Real-time streaming analysis
|
||||
GET /api/v1/voiceprint/results/:id — Get analysis result
|
||||
POST /api/v1/voiceprint/batch — Batch analyze multiple files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. DarkWatch Service — Dark Web Monitoring
|
||||
|
||||
### 4.1 Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ DarkWatch Service │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ │
|
||||
│ │ Scheduler │──▶│ Data │──▶│ Matching & │ │
|
||||
│ │ (Cron/ │ │ Ingestion │ │ Alert Pipeline │ │
|
||||
│ │ Queue) │ │ (APIs, │ │ (Dedup, Severity, │ │
|
||||
│ └─────────────┘ │ Scrapers)│ │ Notification) │ │
|
||||
│ └─────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌────────────────────┐ │
|
||||
│ │ User │ │ Exposure │ │ Report │ │
|
||||
│ │ Watch List │ │ Database │ │ Generator │ │
|
||||
│ │ Manager │ │ (Indexed) │ │ (PDF, Digest) │ │
|
||||
│ └─────────────┘ └─────────────┘ └────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Data Sources
|
||||
|
||||
| Source | Type | Coverage | Cost Model | Tier |
|
||||
|--------|------|----------|------------|------|
|
||||
| **Have I Been Pwned (HIBP)** | API | Email, password breaches | Free (rate limited) / Paid API | All tiers |
|
||||
| **SecurityTrails** | API | DNS, domain exposures | ~$100/month | Plus, Premium |
|
||||
| **Censys** | API | Internet-wide scan data | ~$200/month | Premium |
|
||||
| **Dark web forums** | Scrapers/API | Phone numbers, SSN, emails | ~$500/month (aggregator) | Premium |
|
||||
| **Shodan** | API | IoT, exposed services | ~$250/month | Premium |
|
||||
| **Internal honeypots** | Build | Phone number exposure | Infrastructure cost | All tiers |
|
||||
|
||||
### 4.3 Core Components
|
||||
|
||||
**Watch List Manager**
|
||||
- Stores user-submitted identifiers: emails, phone numbers, SSN (hashed), home addresses
|
||||
- Deduplication: SHA-256 hash of normalized identifiers
|
||||
- Tier-based limits: Basic (2 identifiers), Plus (10), Premium (unlimited)
|
||||
|
||||
**Data Ingestion Pipeline**
|
||||
- Scheduled jobs (BullMQ cron): daily for Basic, hourly for Plus, real-time for Premium
|
||||
- Multi-source aggregation with fallback
|
||||
- Normalization layer: standardize formats across sources
|
||||
- Deduplication: content hash of exposure records
|
||||
|
||||
**Matching Engine**
|
||||
- Exact match: email, phone number, SSN (last 4 digits for Basic, full hash for Premium)
|
||||
- Fuzzy match: name + address combinations for home title monitoring
|
||||
- Severity scoring: based on data type, recency, source reliability
|
||||
|
||||
**Alert Pipeline**
|
||||
- Dedup window: 24 hours per exposure type
|
||||
- Severity levels: INFO (email in old breach), WARNING (phone number recent), CRITICAL (SSN + financial)
|
||||
- Notification channels: email, push notification, SMS (Premium)
|
||||
- Alert fatigue protection: digest mode for INFO, immediate for WARNING+
|
||||
|
||||
**Exposure Database**
|
||||
- PostgreSQL table with GIN index on identifier arrays
|
||||
- Time-series: track exposure history per user
|
||||
- Retention: 5 years for Premium, 1 year for Plus, 30 days for Basic
|
||||
|
||||
### 4.4 Build vs Buy
|
||||
|
||||
| Component | Decision | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| Data aggregation | **Buy** (APIs) | Faster time-to-market, battle-tested sources |
|
||||
| Matching engine | **Build** | Core logic, tier-specific rules, dedup |
|
||||
| Alert system | **Build** | Integrates with shared notification platform |
|
||||
| Honeypot network | **Build** | Differentiator, early detection for phone numbers |
|
||||
| Full alternative | **Identity1** or **WizIQ** API | Evaluate if build cost exceeds ~$2K/month |
|
||||
|
||||
### 4.5 API Surface
|
||||
|
||||
```
|
||||
POST /api/v1/darkwatch/watchlist — Add identifier to watch
|
||||
GET /api/v1/darkwatch/watchlist — List watched identifiers
|
||||
DELETE /api/v1/darkwatch/watchlist/:id — Remove identifier
|
||||
POST /api/v1/darkwatch/scan — Trigger manual scan
|
||||
GET /api/v1/darkwatch/exposures — List user's exposures
|
||||
GET /api/v1/darkwatch/exposures/:id — Exposure detail
|
||||
GET /api/v1/darkwatch/reports — List scan reports
|
||||
POST /api/v1/darkwatch/reports/generate — Generate PDF report
|
||||
GET /api/v1/darkwatch/alerts — List user's alerts
|
||||
PATCH /api/v1/darkwatch/alerts/:id/read — Mark alert as read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SpamShield Service — Spam Call/Text Blocking
|
||||
|
||||
### 5.1 Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ SpamShield Service │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │
|
||||
│ │ Ingestion │──│ Feature │──│ Classifier │ │
|
||||
│ │ (Call/Text │ │ Extractor │ │ (ML + Rules) │ │
|
||||
│ │ Events) │ │ (Metadata, │ │ (Random Forest │ │
|
||||
│ └─────────────┘ │ Content) │ │ + Rule Engine) │ │
|
||||
│ └─────────────┘ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌────────▼─────────┐ │
|
||||
│ │ Action │◀─│ Decision │◀─│ Score │ │
|
||||
│ │ Executor │ │ Engine │ │ Aggregator │ │
|
||||
│ │ (Block, │ │ (Threshold,│ │ (Multi-signal │ │
|
||||
│ │ Flag, │ │ Confidence)│ │ combination) │ │
|
||||
│ │ Notify) │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Spam Detection Layers
|
||||
|
||||
**Layer 1: Number Reputation (Rule-Based)**
|
||||
- Carrier CNAM lookup: identify business vs. personal numbers
|
||||
- Known spam databases: integration with Hiya, Truecaller API
|
||||
- Number age: new numbers (<30 days) flagged as suspicious
|
||||
- Call pattern analysis: high volume from single number = spam
|
||||
- Geographic anomaly: unexpected country/region for user
|
||||
|
||||
**Layer 2: Content Classification (ML)**
|
||||
- SMS text classification: fine-tuned BERT model for spam vs. ham
|
||||
- Feature extraction: URL presence, emoji density, urgency keywords, sender ID
|
||||
- Confidence threshold: 0.85 for auto-block, 0.6-0.85 for flag
|
||||
- Continuous learning: user feedback (false positive/negative) retrains model
|
||||
|
||||
**Layer 3: Behavioral Analysis**
|
||||
- Call frequency patterns: robo-dial detection (>5 calls/minute from same pool)
|
||||
- Time-of-day anomaly: unusual hours for user's timezone
|
||||
- Session analysis: short duration calls (<10s) = likely robo-call
|
||||
- VOIP detection: identify carrier type (VOIP = higher spam probability)
|
||||
|
||||
**Layer 4: Community Intelligence**
|
||||
- Aggregated user reports: crowd-sourced spam number database
|
||||
- Weighted scoring: more reports = higher spam score
|
||||
- Decay function: older reports lose weight over time
|
||||
|
||||
### 5.3 Real-Time Blocking
|
||||
|
||||
**Call Blocking**
|
||||
- Integration: SIP trunking or carrier API (Twilio, Plivo)
|
||||
- Flow: incoming call → API lookup → decision (<200ms) → block/flag/ring
|
||||
- Block action: send to voicemail with "AI-detected spam" greeting
|
||||
- Flag action: show "Likely Spam" on caller ID before answer
|
||||
- False positive recovery: one-tap "keep call" overrides for 30 days
|
||||
|
||||
**Text Blocking**
|
||||
- Integration: SMPP gateway or carrier API
|
||||
- Flow: incoming SMS → content analysis → decision (<500ms) → block/flag
|
||||
- Block action: move to spam folder with preview
|
||||
- Flag action: show banner "Possible Spam" with swipe to keep
|
||||
|
||||
### 5.4 Build vs Buy
|
||||
|
||||
| Component | Decision | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| Number reputation | **Buy** (Hiya + Truecaller) | Established databases, hard to build from scratch |
|
||||
| Content classifier | **Build** (fine-tune BERT) | Domain-specific, continuous improvement |
|
||||
| Behavioral analysis | **Build** | Proprietary data advantage |
|
||||
| Call/text routing | **Buy** (Twilio/Plivo) | Carrier relationships, global coverage |
|
||||
| Community intelligence | **Build** | Network effect, differentiator |
|
||||
| Full alternative | **Syrrex** or **TollBridge** | Evaluate if integration complexity is too high |
|
||||
|
||||
### 5.5 API Surface
|
||||
|
||||
```
|
||||
POST /api/v1/spamshield/calls/analyze — Analyze incoming call
|
||||
POST /api/v1/spamshield/sms/analyze — Analyze incoming SMS
|
||||
GET /api/v1/spamshield/history — User's blocked/flagged history
|
||||
POST /api/v1/spamshield/feedback — Submit false positive/negative
|
||||
POST /api/v1/spamshield/whitelist — Add number to whitelist
|
||||
POST /api/v1/spamshield/blacklist — Add number to blacklist
|
||||
GET /api/v1/spamshield/stats — User's spam statistics
|
||||
WS /api/v1/spamshield/realtime — Real-time event stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Shared Platform Services
|
||||
|
||||
### 6.1 Auth & User Management
|
||||
- NextAuth.js with email/password + OAuth (Google, Apple)
|
||||
- RBAC: user, family_admin, family_member, support
|
||||
- Family group management: up to unlimited members (Premium), 3 (Plus)
|
||||
|
||||
### 6.2 Billing
|
||||
- Stripe subscription management
|
||||
- Tier-based feature gating via middleware
|
||||
- Usage tracking for free tier limits
|
||||
|
||||
### 6.3 Notification System
|
||||
- Multi-channel: email (Resend), push (FCM/APNs), SMS (Twilio)
|
||||
- Template system with localization support
|
||||
- Alert dedup and rate limiting per user
|
||||
|
||||
### 6.4 Analytics
|
||||
- PostHog for product analytics
|
||||
- Custom dashboards: detection rates, false positive rates, conversion funnels
|
||||
- Model performance monitoring: precision, recall, drift detection
|
||||
|
||||
---
|
||||
|
||||
## 7. Development Timeline
|
||||
|
||||
### Phase 1: Foundation (Weeks 1-4)
|
||||
- [ ] Project scaffolding: monorepo (Turborepo), CI/CD pipeline
|
||||
- [ ] Auth service: user registration, login, family groups
|
||||
- [ ] Billing integration: Stripe subscriptions, tier gating
|
||||
- [ ] API gateway: routing, rate limiting, authentication middleware
|
||||
- [ ] Database schema: Prisma models, migrations
|
||||
- [ ] Notification service: email, push infrastructure
|
||||
|
||||
### Phase 2: DarkWatch MVP (Weeks 5-8)
|
||||
- [ ] Watch list manager with CRUD API
|
||||
- [ ] HIBP API integration (first data source)
|
||||
- [ ] Matching engine: exact match for email/phone
|
||||
- [ ] Alert pipeline: email notifications for exposures
|
||||
- [ ] Dashboard: exposure list, watch list management
|
||||
- [ ] Manual scan trigger with job queue
|
||||
|
||||
### Phase 3: SpamShield MVP (Weeks 9-12)
|
||||
- [ ] Number reputation integration (Hiya API)
|
||||
- [ ] SMS content classifier: train initial BERT model
|
||||
- [ ] Call analysis API with rule engine
|
||||
- [ ] Blocking/flagging action executor
|
||||
- [ ] User feedback loop: false positive/negative collection
|
||||
- [ ] Dashboard: spam history, whitelist/blacklist
|
||||
|
||||
### Phase 4: VoicePrint MVP (Weeks 13-16)
|
||||
- [ ] Audio preprocessing pipeline
|
||||
- [ ] ECAPA-TDNN model training on ASVspoof data
|
||||
- [ ] Voice enrollment API with FAISS index
|
||||
- [ ] Batch audio analysis endpoint
|
||||
- [ ] Dashboard: enrollment management, analysis results
|
||||
- [ ] Synthetic voice detection accuracy benchmarking
|
||||
|
||||
### Phase 5: Real-Time Features (Weeks 17-20)
|
||||
- [ ] Real-time call analysis via WebRTC
|
||||
- [ ] Streaming WebSocket alerts
|
||||
- [ ] DarkWatch automated scheduling (tier-based frequency)
|
||||
- [ ] SpamShield real-time call/text interception
|
||||
- [ ] Cross-service alert correlation
|
||||
|
||||
### Phase 6: Beta & Launch (Weeks 21-24)
|
||||
- [ ] Beta testing with 100 users
|
||||
- [ ] Performance optimization: P99 latency targets
|
||||
- [ ] Mobile app (React Native or Tauri)
|
||||
- [ ] Documentation, onboarding flows
|
||||
- [ ] Production deployment, monitoring, alerting
|
||||
- [ ] Launch
|
||||
|
||||
---
|
||||
|
||||
## 8. Infrastructure & Deployment
|
||||
|
||||
### 8.1 Environment Strategy
|
||||
- **Dev**: Docker Compose, local PostgreSQL/Redis
|
||||
- **Staging**: AWS ECS Fargate, RDS PostgreSQL, ElastiCache Redis
|
||||
- **Prod**: AWS ECS Fargate (or EKS if scaling demands), multi-AZ, auto-scaling
|
||||
|
||||
### 8.2 Key Services
|
||||
| Service | Provider | Notes |
|
||||
|---------|----------|-------|
|
||||
| Compute | AWS ECS/Fargate | Container-based, auto-scale |
|
||||
| Database | AWS RDS PostgreSQL | Multi-AZ, automated backups |
|
||||
| Cache | AWS ElastiCache Redis | Cluster mode for BullMQ |
|
||||
| Storage | AWS S3 | Audio files, reports |
|
||||
| CDN | CloudFront | Static assets, dashboard |
|
||||
| Email | Resend | Transactional emails |
|
||||
| SMS | Twilio | Alert notifications, call routing |
|
||||
| ML Training | AWS SageMaker | Model training jobs |
|
||||
| ML Inference | AWS Lambda / ECS | Real-time inference |
|
||||
| Monitoring | Datadog + Sentry | APM, error tracking |
|
||||
|
||||
### 8.3 Security
|
||||
- All data encrypted at rest (AES-256) and in transit (TLS 1.3)
|
||||
- PII field-level encryption for SSN, phone numbers
|
||||
- SOC 2 Type II readiness from launch
|
||||
- OWASP Top 10 compliance
|
||||
- Regular penetration testing (quarterly)
|
||||
- GDPR + CCPA compliance for data retention
|
||||
|
||||
---
|
||||
|
||||
## 9. Key Technical Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Voice model false positives | User trust erosion | Start with "flag" not "block", user feedback loop |
|
||||
| Dark web data source reliability | Stale alerts | Multi-source redundancy, health monitoring |
|
||||
| Real-time latency SLA | Missed spam calls | Edge deployment, <200ms target with fallback |
|
||||
| Scalability of voice analysis | High compute cost | Async batch for non-real-time, GPU spot instances |
|
||||
| API dependency (Hiya, Twilio) | Service outage | Circuit breakers, fallback providers |
|
||||
| Model drift over time | Accuracy degradation | Monthly retraining pipeline, performance monitoring |
|
||||
|
||||
---
|
||||
|
||||
## 10. Team & Resource Estimates
|
||||
|
||||
| Role | Headcount | Phase 1 | Phase 2-3 | Phase 4-6 |
|
||||
|------|-----------|---------|-----------|-----------|
|
||||
| Backend Engineer | 2 | ✓ | ✓ | ✓ |
|
||||
| ML Engineer | 1 | — | — | ✓ |
|
||||
| Frontend Engineer | 1 | ✓ | ✓ | ✓ |
|
||||
| DevOps/SRE | 1 | ✓ | ✓ | ✓ |
|
||||
| QA Engineer | 1 | — | ✓ | ✓ |
|
||||
|
||||
**Estimated monthly burn (engineering only):** ~$45K for 6-person team
|
||||
|
||||
---
|
||||
|
||||
## 11. Success Metrics (Technical)
|
||||
|
||||
| Metric | Target | Measurement |
|
||||
|--------|--------|-------------|
|
||||
| Voice detection accuracy (F1) | >0.90 | ASVspoof benchmark + internal test set |
|
||||
| Spam classification precision | >0.95 | User feedback, labeled test set |
|
||||
| Dark web scan coverage | >3 major sources | Data source inventory |
|
||||
| API P99 latency | <500ms | Datadog APM |
|
||||
| False positive rate (calls) | <2% | User feedback tracking |
|
||||
| System uptime | >99.9% | Uptime monitoring |
|
||||
| Dark web alert freshness | <24h | Time from exposure to alert |
|
||||
@@ -1,124 +0,0 @@
|
||||
# Waitlist Email Sequence — Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how to integrate the waitlist email templates into the waitlist signup flow. The 4-email welcome sequence is designed for new Kordant waitlist signups.
|
||||
|
||||
## Templates Added
|
||||
|
||||
| # | Template ID | Timing | Purpose |
|
||||
|---|---|---|---|
|
||||
| 1 | `waitlist_confirmation` | Immediate | Confirm waitlist signup, show position |
|
||||
| 2 | `waitlist_intro` | Day +1 | Introduce Kordant and the problem it solves |
|
||||
| 3 | `waitlist_features` | Day +3 | Deep dive into product features |
|
||||
| 4 | `waitlist_launch_teaser` | Day +7 | Launch teaser, early adopter perks |
|
||||
|
||||
**File:** `packages/shared-notifications/src/templates/default-templates.ts`
|
||||
- All 4 templates use `buildEmailHtml()` from `waitlist-email-layout.ts` for consistent dark-themed, responsive HTML email rendering with the Kordant brand (Inter font, #0a0f1e dark background, #3b82f6→#06b6d4 gradient accent).
|
||||
- Spanish locale (`es`) is provided for template 1.
|
||||
|
||||
## Variables per Template
|
||||
|
||||
### `waitlist_confirmation`
|
||||
| Variable | Type | Required | Default |
|
||||
|---|---|---|---|
|
||||
| `name` | string | no | "there" |
|
||||
| `position` | string | **yes** | — |
|
||||
| `unsubscribe_url` | string | no | `https://kordant.com/unsubscribe` |
|
||||
|
||||
### `waitlist_intro`
|
||||
| Variable | Type | Required | Default |
|
||||
|---|---|---|---|
|
||||
| `name` | string | no | "there" |
|
||||
| `unsubscribe_url` | string | no | `https://kordant.com/unsubscribe` |
|
||||
|
||||
### `waitlist_features`
|
||||
| Variable | Type | Required | Default |
|
||||
|---|---|---|---|
|
||||
| `name` | string | no | "there" |
|
||||
| `unsubscribe_url` | string | no | `https://kordant.com/unsubscribe` |
|
||||
|
||||
### `waitlist_launch_teaser`
|
||||
| Variable | Type | Required | Default |
|
||||
|---|---|---|---|
|
||||
| `name` | string | no | "there" |
|
||||
| `referral_url` | string | no | `https://kordant.com/waitlist` |
|
||||
| `unsubscribe_url` | string | no | `https://kordant.com/unsubscribe` |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. Immediate Email (on signup)
|
||||
|
||||
In `packages/api/src/routes/waitlist.routes.ts`, after `prisma.waitlistEntry.create()` succeeds:
|
||||
|
||||
```typescript
|
||||
import { EmailService } from '@kordant/shared-notifications';
|
||||
|
||||
// Send confirmation immediately
|
||||
await EmailService.getInstance().sendWithTemplate(email, {
|
||||
templateId: 'waitlist_confirmation',
|
||||
locale: 'en', // derive from request if available
|
||||
variables: {
|
||||
name: body.name || 'there',
|
||||
position: String(waitlistCount),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Scheduled Emails (Day 1, 3, 7)
|
||||
|
||||
Use a job queue (BullMQ is already planned) to schedule the subsequent emails:
|
||||
|
||||
```typescript
|
||||
// On signup, enqueue 3 scheduled jobs
|
||||
await emailQueue.add('send-waitlist-email', {
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
templateId: 'waitlist_intro',
|
||||
}, { delay: 24 * 60 * 60 * 1000 }); // +1 day
|
||||
|
||||
await emailQueue.add('send-waitlist-email', {
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
templateId: 'waitlist_features',
|
||||
}, { delay: 3 * 24 * 60 * 60 * 1000 }); // +3 days
|
||||
|
||||
await emailQueue.add('send-waitlist-email', {
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
templateId: 'waitlist_launch_teaser',
|
||||
}, { delay: 7 * 24 * 60 * 60 * 1000 }); // +7 days
|
||||
```
|
||||
|
||||
If BullMQ is not yet available, use `setTimeout` or a simple `cron`-based approach:
|
||||
|
||||
```typescript
|
||||
// packages/api/src/jobs/waitlist-emails.ts
|
||||
// Run on a cron every hour, check for pending scheduled emails
|
||||
// Store scheduled_at in WaitlistEntry metadata or a separate table
|
||||
```
|
||||
|
||||
### 3. Rate Limiting
|
||||
|
||||
The `EmailService` already enforces a default rate limit of 60 emails/minute per recipient. No additional rate limit config should be needed for the waitlist flow.
|
||||
|
||||
### 4. Unsubscribe Handling
|
||||
|
||||
The email footer includes an `{{unsubscribe_url}}` variable. Implement a standard unsubscribe endpoint:
|
||||
|
||||
- `GET /api/unsubscribe?token=<token>` — one-click unsubscribe
|
||||
- Store unsubscribe preferences per email address
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Unit test:** Verify template rendering with `TemplateService.getInstance().resolveTemplate()`
|
||||
2. **Integration test:** Call `POST /api/waitlist/signup` and verify email is sent (use Resend test API keys)
|
||||
3. **Manual test:** Use Resend email preview to verify rendering across Gmail, Outlook, Apple Mail
|
||||
|
||||
## Rollout Checklist
|
||||
|
||||
- [ ] Add `RESEND_API_KEY` to production environment
|
||||
- [ ] Verify templates render correctly via Resend API
|
||||
- [ ] Test unsubscribe flow
|
||||
- [ ] Verify rate limits for launch-day traffic spike
|
||||
- [ ] Monitor email delivery (bounce rate, open rate) post-launch
|
||||
34
scheduler/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# ─── Build stage ───────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY web/package.json ./web/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY web/ ./web/
|
||||
WORKDIR /app/web
|
||||
RUN pnpm build
|
||||
|
||||
# ─── Runtime stage ────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm@9 tsx
|
||||
COPY --from=builder /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/web/package.json ./web/package.json
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
COPY --from=builder /app/web/.output ./.output
|
||||
COPY --from=builder /app/web/src/server/db ./src/server/db
|
||||
COPY --from=builder /app/web/src/server/jobs ./src/server/jobs
|
||||
COPY --from=builder /app/web/src/server/services ./src/server/services
|
||||
COPY --from=builder /app/web/src/server/lib ./src/server/lib
|
||||
COPY --from=builder /app/web/tsconfig.json ./web/tsconfig.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Default: run scheduler (override CMD for other use cases)
|
||||
CMD ["tsx", "src/server/jobs/start.ts"]
|
||||
35
scheduler/docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
scheduler:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: scheduler/Dockerfile
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JOB_WORKER=true
|
||||
- JOB_PRIMARY=true
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
334
scripts/generate-tokens.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-tokens.mjs
|
||||
*
|
||||
* Reads design-tokens/*.json and generates platform-specific code:
|
||||
* - web/src/theme/tokens.ts
|
||||
* - iOS/Kordant/Theme/GeneratedTokens.swift
|
||||
* - android/app/src/main/res/values/generated_tokens.xml
|
||||
*
|
||||
* Usage: node scripts/generate-tokens.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const tokensDir = join(root, "design-tokens");
|
||||
|
||||
function load(name) {
|
||||
return JSON.parse(readFileSync(join(tokensDir, `${name}.json`), "utf-8"));
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function toCamel(key) {
|
||||
return key.replace(/([A-Z])/g, (_, c) => `_${c}`).toLowerCase();
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace("#", "");
|
||||
return {
|
||||
r: parseInt(h.substring(0, 2), 16),
|
||||
g: parseInt(h.substring(2, 4), 16),
|
||||
b: parseInt(h.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Web (TypeScript) ──────────────────────────────────────────────────────
|
||||
|
||||
function generateWebTokens(colors, typography, spacing, shadows, radius) {
|
||||
const lines = [
|
||||
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
||||
"// Run: node scripts/generate-tokens.mjs",
|
||||
"",
|
||||
];
|
||||
|
||||
// Colors
|
||||
lines.push("export const tokenColors = {");
|
||||
|
||||
// Brand
|
||||
lines.push(" brand: {");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
lines.push(` ${toCamel(key)}: "${val.value}",`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Semantic
|
||||
lines.push(" semantic: {");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (typeof val === "string") {
|
||||
lines.push(` ${toCamel(key)}: "${val}",`);
|
||||
} else if (val.light !== undefined) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Background
|
||||
lines.push(" background: {");
|
||||
for (const [key, val] of Object.entries(colors.background)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Text
|
||||
lines.push(" text: {");
|
||||
for (const [key, val] of Object.entries(colors.text)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
// Border
|
||||
lines.push(" border: {");
|
||||
for (const [key, val] of Object.entries(colors.border)) {
|
||||
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
|
||||
lines.push("};");
|
||||
|
||||
// Typography
|
||||
lines.push("");
|
||||
lines.push("export const tokenTypography = {");
|
||||
lines.push(` fontFamily: "${typography.fontFamily.value}",`);
|
||||
lines.push(` fallback: "${typography.fontFamily.fallback}",`);
|
||||
lines.push(" scale: {");
|
||||
for (const [key, val] of Object.entries(typography.scale)) {
|
||||
lines.push(` ${toCamel(key)}: { size: "${val.size}", lineHeight: "${val.lineHeight}" },`);
|
||||
}
|
||||
lines.push(" },");
|
||||
lines.push(" weights: {");
|
||||
for (const [key, val] of Object.entries(typography.weights)) {
|
||||
lines.push(` ${key}: ${val.value},`);
|
||||
}
|
||||
lines.push(" },");
|
||||
lines.push("};");
|
||||
|
||||
// Spacing
|
||||
lines.push("");
|
||||
lines.push("export const tokenSpacing = {");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
lines.push(` ${key}: "${val.value}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
// Shadows
|
||||
lines.push("");
|
||||
lines.push("export const tokenShadows = {");
|
||||
for (const [key, val] of Object.entries(shadows.scale)) {
|
||||
const shadow = `${val.x}px ${val.y}px ${val.blur}px ${val.spread}px ${val.color}`;
|
||||
lines.push(` ${key}: "${shadow}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
// Radius
|
||||
lines.push("");
|
||||
lines.push("export const tokenRadius = {");
|
||||
for (const [key, val] of Object.entries(radius.scale)) {
|
||||
lines.push(` ${key}: "${val.value}",`);
|
||||
}
|
||||
lines.push("};");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── iOS (Swift) ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateSwiftTokens(colors, typography, spacing) {
|
||||
const lines = [
|
||||
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
|
||||
"// Run: node scripts/generate-tokens.mjs",
|
||||
"",
|
||||
"import SwiftUI",
|
||||
"",
|
||||
];
|
||||
|
||||
// Color extension
|
||||
lines.push("extension Color {");
|
||||
lines.push(" // MARK: - Brand");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
const { r, g, b } = hexToRgb(val.value);
|
||||
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||
lines.push(
|
||||
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(" // MARK: - Semantic");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (val.value) {
|
||||
const { r, g, b } = hexToRgb(val.value);
|
||||
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
|
||||
lines.push(
|
||||
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("}");
|
||||
|
||||
// AdaptiveColor helper
|
||||
lines.push("");
|
||||
lines.push("extension UIColor {");
|
||||
lines.push(" convenience init(hex: String) {");
|
||||
lines.push(
|
||||
' var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)'
|
||||
);
|
||||
lines.push(
|
||||
' hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")'
|
||||
);
|
||||
lines.push(" var rgb: UInt64 = 0");
|
||||
lines.push(" Scanner(string: hexSanitized).scanHexInt64(&rgb)");
|
||||
lines.push(" let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0");
|
||||
lines.push(" let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0");
|
||||
lines.push(" let b = CGFloat(rgb & 0x0000FF) / 255.0");
|
||||
lines.push(" self.init(red: r, green: g, blue: b, alpha: 1.0)");
|
||||
lines.push(" }");
|
||||
lines.push("}");
|
||||
|
||||
// Spacing
|
||||
lines.push("");
|
||||
lines.push("enum DesignTokens {");
|
||||
lines.push(" enum Spacing {");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
if (key === "0") continue; // skip numeric key — invalid in Swift
|
||||
const px = parseInt(val.value);
|
||||
lines.push(` static let ${key}: CGFloat = ${px}`);
|
||||
}
|
||||
lines.push(" }");
|
||||
lines.push("}");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── Android (XML) ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateAndroidTokens(colors, typography, spacing, shadows, radius) {
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
"<!-- Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY -->",
|
||||
"<!-- Run: node scripts/generate-tokens.mjs -->",
|
||||
"<resources>",
|
||||
];
|
||||
|
||||
// Brand colors
|
||||
lines.push(" <!-- Brand -->");
|
||||
for (const [key, val] of Object.entries(colors.brand)) {
|
||||
const name = "brand_" + toCamel(key);
|
||||
lines.push(` <color name="${name}">${val.value}</color>`);
|
||||
}
|
||||
|
||||
// Semantic colors
|
||||
lines.push(" <!-- Semantic -->");
|
||||
for (const [key, val] of Object.entries(colors.semantic)) {
|
||||
if (typeof val === "string") {
|
||||
const name = "sem_" + toCamel(key);
|
||||
lines.push(` <color name="${name}">${val}</color>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme colors
|
||||
lines.push(" <!-- Light theme -->");
|
||||
lines.push(
|
||||
` <color name="bg_light">${colors.background.bg.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="bg_secondary_light">${colors.background.bgSecondary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_primary_light">${colors.text.textPrimary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_secondary_light">${colors.text.textSecondary.light}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="border_light">${colors.border.border.light}</color>`
|
||||
);
|
||||
|
||||
// Dark theme colors
|
||||
lines.push(" <!-- Dark theme -->");
|
||||
lines.push(
|
||||
` <color name="bg_dark">${colors.background.bg.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="bg_secondary_dark">${colors.background.bgSecondary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_primary_dark">${colors.text.textPrimary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="text_secondary_dark">${colors.text.textSecondary.dark}</color>`
|
||||
);
|
||||
lines.push(
|
||||
` <color name="border_dark">${colors.border.border.dark}</color>`
|
||||
);
|
||||
|
||||
// Spacing dimensions
|
||||
lines.push(" <!-- Spacing -->");
|
||||
for (const [key, val] of Object.entries(spacing.scale)) {
|
||||
const dp = val.value.replace("px", "dp");
|
||||
lines.push(` <dimen name="spacing_${key}">${dp}</dimen>`);
|
||||
}
|
||||
|
||||
// Border radius
|
||||
lines.push(" <!-- Corner radius -->");
|
||||
for (const [key, val] of Object.entries(radius.scale)) {
|
||||
const name = key === "full" ? "corner_full" : `corner_${key}`;
|
||||
const dp = val.value.replace("px", "dp");
|
||||
lines.push(` <dimen name="${name}">${dp}</dimen>`);
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
lines.push(" <!-- Typography -->");
|
||||
for (const [key, val] of Object.entries(typography.scale)) {
|
||||
lines.push(` <dimen name="font_${toCamel(key)}">${val.size.replace("px", "sp")}</dimen>`);
|
||||
lines.push(` <dimen name="font_${toCamel(key)}_lh">${val.lineHeight.replace("px", "sp")}</dimen>`);
|
||||
}
|
||||
|
||||
lines.push("</resources>");
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const colors = load("colors");
|
||||
const typography = load("typography");
|
||||
const spacing = load("spacing");
|
||||
const shadows = load("shadows");
|
||||
const radius = load("radius");
|
||||
|
||||
// Web
|
||||
const webPath = join(root, "web", "src", "theme", "tokens.ts");
|
||||
mkdirSync(dirname(webPath), { recursive: true });
|
||||
writeFileSync(webPath, generateWebTokens(colors, typography, spacing, shadows, radius));
|
||||
console.log(`✅ ${webPath}`);
|
||||
|
||||
// iOS
|
||||
const iosPath = join(root, "iOS", "Kordant", "Theme", "GeneratedTokens.swift");
|
||||
mkdirSync(dirname(iosPath), { recursive: true });
|
||||
writeFileSync(iosPath, generateSwiftTokens(colors, typography, spacing));
|
||||
console.log(`✅ ${iosPath}`);
|
||||
|
||||
// Android
|
||||
const androidPath = join(
|
||||
root,
|
||||
"android",
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"res",
|
||||
"values",
|
||||
"generated_tokens.xml"
|
||||
);
|
||||
mkdirSync(dirname(androidPath), { recursive: true });
|
||||
writeFileSync(androidPath, generateAndroidTokens(colors, typography, spacing, shadows, radius));
|
||||
console.log(`✅ ${androidPath}`);
|
||||
|
||||
console.log("🎨 Token generation complete.");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Trend, Rate } from 'k6/metrics';
|
||||
|
||||
export const errorRate = new Rate('error_rate');
|
||||
|
||||
export function getBaseUrl() {
|
||||
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function getTargetRps() {
|
||||
return parseInt(__ENV.TARGET_RPS || '500', 10);
|
||||
}
|
||||
|
||||
export function getDuration() {
|
||||
return __ENV.DURATION || '300s';
|
||||
}
|
||||
|
||||
export function defaultThresholds(p99ms) {
|
||||
return {
|
||||
thresholds: {
|
||||
http_req_duration: [`p(99)<${p99ms}`],
|
||||
error_rate: ['rate<0.01'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function checkResponse(res, expectedStatus = 200) {
|
||||
const pass = check(res, {
|
||||
'status is expected': (r) => r.status === expectedStatus,
|
||||
'response time OK': (r) => r.timings.duration < 5000,
|
||||
});
|
||||
errorRate.add(!pass);
|
||||
return pass;
|
||||
}
|
||||
|
||||
export function randomString(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const autoscaleMetric = new Trend('autoscale_vu_count');
|
||||
|
||||
export function recordAutoscaleMetric(vuCount) {
|
||||
autoscaleMetric.add(vuCount);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Combined load test runner for all Kordant services
|
||||
# Usage: ./run-all.sh [service]
|
||||
# service: all (default), api, darkwatch, spamshield, voiceprint
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPORT_DIR="${SCRIPT_DIR}/reports"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
THRESHOLD_FILE="${REPORT_DIR}/threshold-results.json"
|
||||
SERVICE="${1:-all}"
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
BASE_URL="${LOAD_TEST_BASE_URL:-http://localhost:3000}"
|
||||
TARGET_RPS="${TARGET_RPS:-500}"
|
||||
DURATION="${DURATION:-300s}"
|
||||
API_TOKEN="${API_TOKEN:-}"
|
||||
if [[ -z "$API_TOKEN" ]]; then
|
||||
echo "⚠️ API_TOKEN not set (load tests will run without auth)"
|
||||
fi
|
||||
|
||||
echo "=== Kordant Combined Load Test ==="
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo "Target RPS: $TARGET_RPS"
|
||||
echo "Duration: $DURATION"
|
||||
echo "Service: $SERVICE"
|
||||
echo ""
|
||||
|
||||
K6_OPTS="--summary-export ${REPORT_DIR}/summary-${TIMESTAMP}.json"
|
||||
|
||||
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
|
||||
K6_OPTS="$K6_OPTS --out cloud"
|
||||
echo "k6 cloud output: enabled"
|
||||
fi
|
||||
|
||||
declare -A EXIT_CODES
|
||||
ALL_PASSED=true
|
||||
SERVICE_ENV="BASE_URL=$BASE_URL TARGET_RPS=$TARGET_RPS DURATION=$DURATION API_TOKEN=$API_TOKEN"
|
||||
|
||||
run_service_test() {
|
||||
local name=$1
|
||||
local script=$2
|
||||
local summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
|
||||
|
||||
echo ""
|
||||
echo "=== Running $name Load Test ==="
|
||||
|
||||
local opts="--summary-export $summary_file"
|
||||
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
|
||||
opts="$opts --out cloud"
|
||||
fi
|
||||
|
||||
set +e
|
||||
eval "$SERVICE_ENV" k6 run $opts "$script"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
EXIT_CODES[$name]=$EXIT_CODE
|
||||
if [[ $EXIT_CODE -ne 0 ]]; then
|
||||
ALL_PASSED=false
|
||||
echo "❌ $name load test FAILED (exit code: $EXIT_CODE)"
|
||||
else
|
||||
echo "✅ $name load test PASSED"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "api" ]]; then
|
||||
run_service_test "api" "${SCRIPT_DIR}/services/api.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "darkwatch" ]]; then
|
||||
run_service_test "darkwatch" "${SCRIPT_DIR}/services/darkwatch.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "spamshield" ]]; then
|
||||
run_service_test "spamshield" "${SCRIPT_DIR}/services/spamshield.js"
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE" == "all" || "$SERVICE" == "voiceprint" ]]; then
|
||||
run_service_test "voiceprint" "${SCRIPT_DIR}/services/voiceprint.js"
|
||||
fi
|
||||
|
||||
# Aggregate threshold results from all service summaries
|
||||
echo ""
|
||||
echo "=== Load Test Results ==="
|
||||
|
||||
# Build threshold-results.json from k6 summary exports
|
||||
jq -n --arg timestamp "$TIMESTAMP" --arg base_url "$BASE_URL" --arg target_rps "$TARGET_RPS" \
|
||||
'{timestamp: $timestamp, base_url: $base_url, target_rps: $target_rps, services: {}}' \
|
||||
> "$THRESHOLD_FILE"
|
||||
|
||||
for name in "${!EXIT_CODES[@]}"; do
|
||||
summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
|
||||
if [[ -f "$summary_file" ]]; then
|
||||
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
|
||||
'.services[$name] = {
|
||||
exitCode: $exit_code,
|
||||
passed: ($exit_code == 0),
|
||||
metrics: (input | .metrics // {})
|
||||
}' \
|
||||
"$THRESHOLD_FILE" "$summary_file" \
|
||||
> "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
|
||||
else
|
||||
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
|
||||
'.services[$name] = {exitCode: $exit_code, passed: ($exit_code == 0)}' \
|
||||
"$THRESHOLD_FILE" > "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Threshold results saved to: $THRESHOLD_FILE"
|
||||
|
||||
for service in "${!EXIT_CODES[@]}"; do
|
||||
status="pass"
|
||||
[[ ${EXIT_CODES[$service]} -ne 0 ]] && status="fail"
|
||||
echo "$service: $status"
|
||||
done
|
||||
|
||||
echo ""
|
||||
if $ALL_PASSED; then
|
||||
echo "✅ All load tests passed"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Some load tests failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,62 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const notificationLatency = new Trend('notification_p99');
|
||||
const correlationLatency = new Trend('correlation_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(250).thresholds,
|
||||
notification_p99: ['p(99)<500'],
|
||||
correlation_p99: ['p(99)<300'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('API Health', function () {
|
||||
const res = http.get(`${BASE_URL}/health`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
|
||||
group('Notifications', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `user-${randomString()}`,
|
||||
channel: 'email',
|
||||
message: 'Load test notification',
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/notifications`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
notificationLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Correlation', function () {
|
||||
const res = http.get(`${BASE_URL}/correlation/events?limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
correlationLatency.add(res.timings.duration);
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const scanLatency = new Trend('scan_p99');
|
||||
const watchlistLatency = new Trend('watchlist_p99');
|
||||
const alertLatency = new Trend('alert_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(200).thresholds,
|
||||
scan_p99: ['p(99)<300'],
|
||||
watchlist_p99: ['p(99)<200'],
|
||||
alert_p99: ['p(99)<250'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('Darkwatch Scan', function () {
|
||||
const payload = JSON.stringify({
|
||||
type: 'email',
|
||||
value: `loadtest-${randomString()}@example.com`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/darkwatch/scan`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
scanLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Watchlist', function () {
|
||||
const res = http.get(`${BASE_URL}/watchlist?page=1&limit=20`, { headers });
|
||||
checkResponse(res, 200);
|
||||
watchlistLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Alerts', function () {
|
||||
const res = http.get(`${BASE_URL}/alerts?status=open&limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
alertLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Exposure Check', function () {
|
||||
const res = http.get(`${BASE_URL}/exposure/summary`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const smsClassifyP99 = new Trend('sms_classify_p99');
|
||||
const numberReputationP99 = new Trend('number_reputation_p99');
|
||||
const callAnalyzeP99 = new Trend('call_analyze_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(400).thresholds,
|
||||
sms_classify_p99: ['p(99)<150'],
|
||||
number_reputation_p99: ['p(99)<300'],
|
||||
call_analyze_p99: ['p(99)<400'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('SMS Text Classification', function () {
|
||||
const payload = JSON.stringify({
|
||||
text: `Is this message a spam attempt? ${randomString(16)}`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/sms/classify`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
smsClassifyP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Number Reputation Check', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/number/reputation`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
numberReputationP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Call Analysis', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
callTime: new Date().toISOString(),
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/call/analyze`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
callAnalyzeP99.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Spam Feedback', function () {
|
||||
const payload = JSON.stringify({
|
||||
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
|
||||
isSpam: false,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/spamshield/feedback`, payload, { headers });
|
||||
check(res, { 'feedback status is 201': (r) => r.status === 201 });
|
||||
});
|
||||
|
||||
group('Spam History', function () {
|
||||
const res = http.get(`${BASE_URL}/spamshield/history?limit=10`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
|
||||
group('Spam Statistics', function () {
|
||||
const res = http.get(`${BASE_URL}/spamshield/statistics`, { headers });
|
||||
checkResponse(res, 200);
|
||||
});
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
|
||||
|
||||
const enrollmentLatency = new Trend('enrollment_p99');
|
||||
const verificationLatency = new Trend('verification_p99');
|
||||
const modelLatency = new Trend('model_retrieval_p99');
|
||||
|
||||
const TARGET_RPS = getTargetRps();
|
||||
const DURATION = getDuration();
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
sustained_load: {
|
||||
executor: 'constant-arrival-rate',
|
||||
duration: DURATION,
|
||||
rate: TARGET_RPS,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 100,
|
||||
startTime: '0s',
|
||||
exec: 'default',
|
||||
tags: { scenario: 'sustained_load' },
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
...defaultThresholds(250).thresholds,
|
||||
enrollment_p99: ['p(99)<500'],
|
||||
verification_p99: ['p(99)<250'],
|
||||
model_retrieval_p99: ['p(99)<100'],
|
||||
},
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const AUTH_TOKEN = __ENV.API_TOKEN || '';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
};
|
||||
|
||||
export default function () {
|
||||
group('Voiceprint Enrollment', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `loadtest-${randomString()}`,
|
||||
audioSample: 'base64-encoded-audio-data-placeholder',
|
||||
sampleRate: 16000,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
enrollmentLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Voiceprint Verification', function () {
|
||||
const payload = JSON.stringify({
|
||||
userId: `loadtest-${randomString()}`,
|
||||
audioSample: 'base64-encoded-audio-data-placeholder',
|
||||
sampleRate: 16000,
|
||||
});
|
||||
const res = http.post(`${BASE_URL}/voiceprint/verify`, payload, { headers });
|
||||
checkResponse(res, 200);
|
||||
verificationLatency.add(res.timings.duration);
|
||||
});
|
||||
|
||||
group('Model Retrieval', function () {
|
||||
const res = http.get(`${BASE_URL}/voiceprint/model/${randomString()}`, { headers });
|
||||
checkResponse(res, 200);
|
||||
modelLatency.add(res.timings.duration);
|
||||
});
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# GA4 Setup Script for Kordant
|
||||
# Two modes:
|
||||
# 1. MANUAL: Step-by-step guide for GA web console (no credentials needed)
|
||||
# 2. AUTOMATED: Creates property + stream via Admin API (requires GCP service account)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-ga4.sh # Print manual instructions
|
||||
# ./scripts/setup-ga4.sh --auto # Automated setup (needs GOOGLE_APPLICATION_CREDENTIALS)
|
||||
# ./scripts/setup-ga4.sh --env-only # Just print what to put in .env
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
show_manual_guide() {
|
||||
cat <<'GUIDE'
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ Kordant — Manual GA4 Setup Guide ║
|
||||
║ ~5 minutes in Google Analytics web console ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
STEP 1 — Create GA4 Property
|
||||
1. Go to https://analytics.google.com/
|
||||
2. Admin → Create Property → "Kordant"
|
||||
3. Set reporting time zone, currency
|
||||
4. Click "Create"
|
||||
|
||||
STEP 2 — Configure Data Stream
|
||||
1. In the new property: Admin → Data Streams → Add Stream → Web
|
||||
2. Website URL: https://kordant.ai
|
||||
3. Stream name: "Kordant Landing Page"
|
||||
4. Click "Create stream"
|
||||
5. Copy the Measurement ID (format: G-XXXXXXXXXX)
|
||||
|
||||
STEP 3 — Create API Secret
|
||||
1. In the data stream details: Measurement Protocol API secrets → Create
|
||||
2. Nickname: "Kordant Backend"
|
||||
3. Copy the API Secret
|
||||
|
||||
STEP 4 — Set Up Conversion Events
|
||||
1. In GA4: Admin → Conversions → New conversion event
|
||||
2. Create: "waitlist_signup"
|
||||
3. Create: "page_view" (auto-tracked by default)
|
||||
4. Optionally: "conversion" (for tracked conversions)
|
||||
|
||||
STEP 5 — Configure Environment
|
||||
Add to .env (or .env.prod for production):
|
||||
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
GA4_API_SECRET=<api-secret-from-step-3>
|
||||
|
||||
STEP 6 — Verify
|
||||
curl -X POST "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=<secret>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_id":"test-001","events":[{"name":"page_view"}]}'
|
||||
GUIDE
|
||||
}
|
||||
|
||||
setup_automated() {
|
||||
if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then
|
||||
echo "ERROR: GOOGLE_APPLICATION_CREDENTIALS not set"
|
||||
echo "Set it to the path of your GCP service account JSON key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "ERROR: node is required for automated setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Automated GA4 Setup ---"
|
||||
echo "Using service account: $GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
# Generate a setup script that uses the Google Admin API
|
||||
cat > /tmp/setup-ga4-auto.mjs << 'SCRIPT'
|
||||
import { google } from 'googleapis';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
async function main() {
|
||||
const creds = JSON.parse(readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf-8'));
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials: creds,
|
||||
scopes: ['https://www.googleapis.com/auth/analytics.edit'],
|
||||
});
|
||||
|
||||
const analyticsAdmin = google.analyticsadmin({ version: 'v1beta', auth });
|
||||
|
||||
// Step 1: Create GA4 property
|
||||
console.log('Creating GA4 property...');
|
||||
const property = await analyticsAdmin.properties.create({
|
||||
requestBody: {
|
||||
displayName: 'Kordant',
|
||||
industryCategory: 'TECHNOLOGY',
|
||||
timeZone: 'America/New_York',
|
||||
currencyCode: 'USD',
|
||||
parent: `accounts/${creds.account_id || '103950747'}`, // Replace with actual account ID
|
||||
},
|
||||
});
|
||||
console.log(`Property created: ${property.data.name}`);
|
||||
|
||||
// Step 2: Create web data stream
|
||||
console.log('Creating web data stream...');
|
||||
const stream = await analyticsAdmin.properties.dataStreams.create({
|
||||
parent: property.data.name,
|
||||
requestBody: {
|
||||
type: 'WEB_DATA_STREAM',
|
||||
displayName: 'Kordant Landing Page',
|
||||
webStreamData: {
|
||||
defaultUri: 'https://kordant.ai',
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`Data stream created: ${stream.data.name}`);
|
||||
console.log(`Measurement ID: ${stream.data.webStreamData.measurementId}`);
|
||||
|
||||
// Step 3: Create conversion events
|
||||
console.log('Creating conversion events...');
|
||||
for (const event of ['waitlist_signup']) {
|
||||
try {
|
||||
await analyticsAdmin.properties.conversionEvents.create({
|
||||
parent: property.data.name,
|
||||
requestBody: { eventName: event },
|
||||
});
|
||||
console.log(`Conversion event created: ${event}`);
|
||||
} catch (e) {
|
||||
console.log(`Conversion event ${event} may already exist: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Output results
|
||||
const output = {
|
||||
propertyId: property.data.name.replace('properties/', ''),
|
||||
measurementId: stream.data.webStreamData.measurementId,
|
||||
streamId: stream.data.name,
|
||||
streamName: stream.data.displayName,
|
||||
};
|
||||
writeFileSync('/tmp/ga4-setup-output.json', JSON.stringify(output, null, 2));
|
||||
console.log('\nResults saved to /tmp/ga4-setup-output.json');
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
SCRIPT
|
||||
|
||||
echo ""
|
||||
echo "To run the automated setup:"
|
||||
echo " 1. Update the account_id in the script above"
|
||||
echo " 2. cd $PROJECT_DIR && node /tmp/setup-ga4-auto.mjs"
|
||||
echo ""
|
||||
echo "NOTE: You need to provide the Google Analytics account ID."
|
||||
echo "Find it at: https://analytics.google.com/ → Admin → Account Settings"
|
||||
}
|
||||
|
||||
show_env_only() {
|
||||
cat <<'ENV'
|
||||
Required .env additions for Kordant analytics:
|
||||
|
||||
GA4_MEASUREMENT_ID=G-XXXXXXXXXX # From GA4 data stream
|
||||
GA4_API_SECRET= # From GA4 Measurement Protocol API secrets
|
||||
MIXPANEL_TOKEN= # Mixpanel project token
|
||||
MIXPANEL_API_SECRET= # Mixpanel project API secret
|
||||
ENV
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--auto)
|
||||
setup_automated
|
||||
;;
|
||||
--env-only)
|
||||
show_env_only
|
||||
;;
|
||||
*)
|
||||
show_manual_guide
|
||||
;;
|
||||
esac
|
||||
252
scripts/setup-pan.sh
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Kordant Scheduler — Pan Server Setup ─────────────────────
|
||||
# Usage:
|
||||
# bash scripts/setup-pan.sh # interactively
|
||||
# bash scripts/setup-pan.sh user@pan # interactively, remote
|
||||
# bash scripts/setup-pan.sh -u <url> -d <url> ...
|
||||
#
|
||||
# Flags:
|
||||
# -u, --gitea-url URL Gitea clone URL (skip prompt)
|
||||
# -d, --db-url URL Turso database URL (skip prompt)
|
||||
# -t, --db-token TOKEN Turso auth token (skip prompt)
|
||||
# -k, --hooks-dir DIR Gitea hooks directory (skip prompt; empty=skip hook)
|
||||
# --host HOST SSH host (default: pan)
|
||||
# -y, --non-interactive Skip all prompts (requires -u, -d, -t)
|
||||
# -h, --help Show this message
|
||||
|
||||
# ─── Defaults ───────────────────────────────────────────────────
|
||||
|
||||
PAN_HOST=""
|
||||
GITEA_URL=""
|
||||
DB_URL=""
|
||||
DB_TOKEN=""
|
||||
HOOKS_DIR=""
|
||||
NON_INTERACTIVE=false
|
||||
REPO_DIR="/opt/kordant"
|
||||
|
||||
# ─── Help ───────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# //p; s/^#$//p' "$0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ─── Parse flags ────────────────────────────────────────────────
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-u|--gitea-url) GITEA_URL="$2"; shift 2 ;;
|
||||
-d|--db-url) DB_URL="$2"; shift 2 ;;
|
||||
-t|--db-token) DB_TOKEN="$2"; shift 2 ;;
|
||||
-k|--hooks-dir) HOOKS_DIR="$2"; shift 2 ;;
|
||||
--host) PAN_HOST="$2"; shift 2 ;;
|
||||
-y|--non-interactive) NON_INTERACTIVE=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
*) PAN_HOST="${PAN_HOST:-$1}"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PAN_HOST="${PAN_HOST:-pan}"
|
||||
|
||||
# ─── Remote detection ───────────────────────────────────────────
|
||||
|
||||
if [ "$(hostname)" != "pan" ] && [ "$(hostname -s 2>/dev/null)" != "pan" ]; then
|
||||
if [ -n "${SSH_CONNECTION:-}" ]; then
|
||||
echo "Already connected to pan via SSH."
|
||||
else
|
||||
echo "Not on pan. Connecting to $PAN_HOST via SSH..."
|
||||
# Rebuild the flag string to pass through
|
||||
FLAGS=""
|
||||
[ -n "$GITEA_URL" ] && FLAGS="$FLAGS -u '$GITEA_URL'"
|
||||
[ -n "$DB_URL" ] && FLAGS="$FLAGS -d '$DB_URL'"
|
||||
[ -n "$DB_TOKEN" ] && FLAGS="$FLAGS -t '$DB_TOKEN'"
|
||||
[ -n "$HOOKS_DIR" ] && FLAGS="$FLAGS -k '$HOOKS_DIR'"
|
||||
$NON_INTERACTIVE && FLAGS="$FLAGS -y"
|
||||
scp "$0" "${PAN_HOST}:/tmp/kordant-setup.sh"
|
||||
ssh -t "$PAN_HOST" "sudo bash /tmp/kordant-setup.sh $FLAGS && rm /tmp/kordant-setup.sh"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Kordant Scheduler Setup (running on pan) ==="
|
||||
|
||||
# ─── Step 1: Install prerequisites ─────────────────────────────
|
||||
|
||||
echo "--- Step 1: Checking prerequisites ---"
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Installing Docker..."
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
systemctl enable --now docker
|
||||
else
|
||||
echo "Docker already installed."
|
||||
fi
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
echo "Installing Docker Compose plugin..."
|
||||
apt-get update && apt-get install -y docker-compose-plugin
|
||||
else
|
||||
echo "Docker Compose plugin already installed."
|
||||
fi
|
||||
|
||||
if ! command -v git &>/dev/null; then
|
||||
echo "Installing git..."
|
||||
apt-get update && apt-get install -y git
|
||||
else
|
||||
echo "Git already installed."
|
||||
fi
|
||||
|
||||
# ─── Step 2: Clone or pull the repo ────────────────────────────
|
||||
|
||||
echo "--- Step 2: Setting up repo at $REPO_DIR ---"
|
||||
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
if [ -z "$GITEA_URL" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --gitea-url is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
while [ -z "$GITEA_URL" ]; do
|
||||
read -rp "Gitea clone URL (e.g. http://localhost:3000/kordant/kordant.git): " GITEA_URL
|
||||
done
|
||||
fi
|
||||
echo "Cloning $GITEA_URL ..."
|
||||
git clone "$GITEA_URL" "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
else
|
||||
echo "Repo exists. Pulling latest..."
|
||||
cd "$REPO_DIR"
|
||||
git pull
|
||||
fi
|
||||
|
||||
# ─── Step 3: Create .env with credentials ──────────────────────
|
||||
|
||||
echo "--- Step 3: Environment file ---"
|
||||
|
||||
if [ ! -f "$REPO_DIR/.env" ]; then
|
||||
if [ -z "$DB_URL" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --db-url is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
read -rp " DATABASE_URL (e.g. libsql://kordant.turso.io): " DB_URL
|
||||
fi
|
||||
if [ -z "$DB_TOKEN" ]; then
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ --db-token is required in non-interactive mode"
|
||||
exit 1
|
||||
fi
|
||||
read -rsp " DATABASE_AUTH_TOKEN: " DB_TOKEN
|
||||
echo ""
|
||||
fi
|
||||
|
||||
cat > "$REPO_DIR/.env" << ENVEOF
|
||||
# ─── Kordant Scheduler Environment ─────────────────────────────
|
||||
# Turso database
|
||||
DATABASE_URL="${DB_URL}"
|
||||
DATABASE_AUTH_TOKEN="${DB_TOKEN}"
|
||||
|
||||
# Job queue (local Redis container)
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
JOB_WORKER=true
|
||||
JOB_PRIMARY=true
|
||||
ENVEOF
|
||||
chmod 600 "$REPO_DIR/.env"
|
||||
echo "✅ Created $REPO_DIR/.env"
|
||||
else
|
||||
echo "$REPO_DIR/.env already exists, keeping it."
|
||||
fi
|
||||
|
||||
# ─── Step 4: Gitea post-receive hook ────────────────────────────
|
||||
|
||||
echo "--- Step 4: Gitea post-receive hook ---"
|
||||
|
||||
if [ -z "$HOOKS_DIR" ] && ! $NON_INTERACTIVE; then
|
||||
read -rp "Gitea repo hooks directory (or leave blank to skip): " HOOKS_DIR
|
||||
fi
|
||||
|
||||
if [ -n "$HOOKS_DIR" ]; then
|
||||
if [ ! -d "$HOOKS_DIR" ]; then
|
||||
echo " Directory not found: $HOOKS_DIR"
|
||||
if $NON_INTERACTIVE; then
|
||||
echo "❌ hooks-dir does not exist"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
HOOK_FILE="$HOOKS_DIR/post-receive"
|
||||
cat > "$HOOK_FILE" << 'HOOKEOF'
|
||||
#!/bin/bash
|
||||
cd /opt/kordant
|
||||
git pull origin main
|
||||
docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
HOOKEOF
|
||||
chmod +x "$HOOK_FILE"
|
||||
echo "✅ Post-receive hook installed at $HOOK_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Skipping post-receive hook."
|
||||
fi
|
||||
|
||||
# ─── Step 5: Create systemd service ────────────────────────────
|
||||
|
||||
echo "--- Step 5: Systemd service ---"
|
||||
|
||||
SERVICE_NAME="kordant-scheduler"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
if [ ! -f "$SERVICE_FILE" ]; then
|
||||
echo "Creating $SERVICE_FILE..."
|
||||
cat > "$SERVICE_FILE" << SERVICEEOF
|
||||
[Unit]
|
||||
Description=Kordant Background Job Scheduler
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=$REPO_DIR
|
||||
ExecStart=docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
ExecStop=docker compose -f scheduler/docker-compose.yml down
|
||||
ExecReload=docker compose -f scheduler/docker-compose.yml pull && docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICEEOF
|
||||
systemctl daemon-reload
|
||||
else
|
||||
echo "$SERVICE_FILE already exists."
|
||||
fi
|
||||
|
||||
# ─── Step 6: Start / restart the scheduler ─────────────────────
|
||||
|
||||
echo "--- Step 6: Starting scheduler ---"
|
||||
|
||||
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
|
||||
|
||||
cd "$REPO_DIR"
|
||||
docker compose -f scheduler/docker-compose.yml pull 2>/dev/null || true
|
||||
docker compose -f scheduler/docker-compose.yml up -d --build
|
||||
|
||||
echo ""
|
||||
echo "=== Scheduler status ==="
|
||||
sleep 3
|
||||
docker compose -f scheduler/docker-compose.yml ps
|
||||
|
||||
echo ""
|
||||
echo "=== Kordant scheduler setup complete ==="
|
||||
echo " Repo: $REPO_DIR"
|
||||
echo " Logs: journalctl -u kordant-scheduler -f"
|
||||
echo " Shell: cd $REPO_DIR && docker compose -f scheduler/docker-compose.yml logs -f"
|
||||
echo " .env: $REPO_DIR/.env"
|
||||
@@ -44,9 +44,9 @@ Tasks
|
||||
- [x] 37 — Android App — API Client, tRPC Bridge, and Offline Support → `37-android-api-client.md`
|
||||
- [x] 38 — Android App — Dashboard and Service Screens → `38-android-service-screens.md`
|
||||
- [x] 39 — Android App — Push Notifications, Biometrics, Voice Enrollment, Call Screening → `39-android-native-features.md`
|
||||
- [ ] 40 — Shared Mobile Assets — Icons, Colors, Typography, and Brand Guidelines → `40-shared-mobile-assets.md`
|
||||
- [ ] 41 — Cleanup — Remove Legacy packages/, services/, and server/ Directories → `41-cleanup-legacy.md`
|
||||
- [ ] 42 — Deployment — Update Docker, CI/CD, and Environment Configuration → `42-deployment-config.md`
|
||||
- [x] 40 — Shared Mobile Assets — Icons, Colors, Typography, and Brand Guidelines → `40-shared-mobile-assets.md`
|
||||
- [x] 41 — Cleanup — Remove Legacy packages/, services/, and server/ Directories → `41-cleanup-legacy.md`
|
||||
- [x] 42 — Deployment — Update Docker, CI/CD, and Environment Configuration → `42-deployment-config.md`
|
||||
|
||||
Dependencies
|
||||
- 01 depends on nothing (root task)
|
||||
|
||||
@@ -6,11 +6,11 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
- [ ] 01 — Inline index page sections following Lendair pattern → `01-inline-index-sections.md`
|
||||
- [ ] 02 — Admin routes with controls and services dashboard → `02-admin-routes-dashboard.md`
|
||||
- [ ] 03 — Blog route with DB integration, featured post, and chronological feed → `03-blog-database-integration.md`
|
||||
- [ ] 04 — Create blog post content (scam advice, AI detection, etc.) → `04-blog-content-creation.md`
|
||||
- [ ] 05 — Dedicated /pricing and /features pages → `05-pricing-features-pages.md`
|
||||
- [ ] 06 — Auth-contextual navbar with dynamic links → `06-auth-contextual-navbar.md`
|
||||
- [x] 02 — Admin routes with controls and services dashboard → `02-admin-routes-dashboard.md`
|
||||
- [x] 03 — Blog route with DB integration, featured post, and chronological feed → `03-blog-database-integration.md`
|
||||
- [x] 04 — Create blog post content (scam advice, AI detection, etc.) → `04-blog-content-creation.md`
|
||||
- [x] 05 — Dedicated /pricing and /features pages → `05-pricing-features-pages.md`
|
||||
- [x] 06 — Auth-contextual navbar with dynamic links → `06-auth-contextual-navbar.md`
|
||||
- [ ] 07 — Fix Apple logo SVG in social auth buttons → `07-fix-apple-logo-svg.md`
|
||||
|
||||
Dependencies
|
||||
|
||||
11
todos.txt
@@ -1,11 +0,0 @@
|
||||
- [ ] fix translation of index sections
|
||||
to be like in
|
||||
~/code/Lendair/web/src/routes/index.tsx
|
||||
- [ ] admin routes with appropriate controls and services dashboard
|
||||
- [ ] make a /blog route that shows a chronological feed with featured one (if one is marked as such in db - should be available to manage in admin route
|
||||
- [ ] create actual blogs filled with good advice to avoid scams and what to do when one has happened, ai detection advice etc.
|
||||
- [ ] need pricing page, features/products page
|
||||
- [ ] navbar should show links to main dashboard, and then specific products/features when logged in, so auth contextual
|
||||
rendering
|
||||
- [ ] apple logo svg is fucked up
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, onMount, onCleanup, Show } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import { Typewriter } from "~/components/ui/Typewriter";
|
||||
@@ -119,11 +119,19 @@ function ThemeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
const marketingLinks = [
|
||||
{ label: "Features", href: "/features" },
|
||||
{ label: "Pricing", href: "/pricing" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
];
|
||||
|
||||
const productLinks = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "DarkWatch", href: "/darkwatch" },
|
||||
{ label: "VoicePrint", href: "/voiceprint" },
|
||||
{ label: "SpamShield", href: "/spamshield" },
|
||||
{ label: "HomeTitle", href: "/hometitle" },
|
||||
{ label: "RemoveBrokers", href: "/removebrokers" },
|
||||
];
|
||||
|
||||
function RealtimeIndicator() {
|
||||
@@ -174,6 +182,7 @@ function RealtimeIndicator() {
|
||||
export default function Navbar() {
|
||||
const [mobileOpen, setMobileOpen] = createSignal(false);
|
||||
const [scrolled, setScrolled] = createSignal(false);
|
||||
const location = useLocation();
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => {
|
||||
@@ -183,6 +192,29 @@ export default function Navbar() {
|
||||
onCleanup(() => window.removeEventListener("scroll", onScroll));
|
||||
});
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/dashboard") return location.pathname === "/dashboard";
|
||||
return location.pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const NavLink = (props: { href: string; label: string; mobile?: boolean }) => (
|
||||
<A
|
||||
href={props.href}
|
||||
class={cn(
|
||||
props.mobile
|
||||
? "block px-3 py-2 rounded-lg text-base font-medium transition-colors"
|
||||
: "text-sm font-medium transition-colors",
|
||||
isActive(props.href)
|
||||
? "text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
props.mobile && !isActive(props.href) && "hover:bg-[var(--color-bg-secondary)]",
|
||||
)}
|
||||
onClick={() => props.mobile && setMobileOpen(false)}
|
||||
>
|
||||
{props.label}
|
||||
</A>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
class={cn(
|
||||
@@ -201,14 +233,12 @@ export default function Navbar() {
|
||||
</A>
|
||||
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
{navLinks.map(link => (
|
||||
<A
|
||||
href={link.href}
|
||||
class="text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</A>
|
||||
))}
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
</SignedIn>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
@@ -216,9 +246,6 @@ export default function Navbar() {
|
||||
<SignedIn>
|
||||
<UserButton showName />
|
||||
<RealtimeIndicator />
|
||||
<Button variant="secondary" size="sm">
|
||||
<A href="/dashboard">Dashboard</A>
|
||||
</Button>
|
||||
</SignedIn>
|
||||
<SignedOut>
|
||||
<Button variant="secondary" size="sm">
|
||||
@@ -276,19 +303,20 @@ export default function Navbar() {
|
||||
<Show when={mobileOpen()}>
|
||||
<div class="md:hidden glass border-t border-[var(--color-border)]">
|
||||
<div class="px-4 py-4 space-y-1">
|
||||
{navLinks.map(link => (
|
||||
<A
|
||||
href={link.href}
|
||||
class="block px-3 py-2 rounded-lg text-base font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</A>
|
||||
))}
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedIn>
|
||||
<div class="pt-3 flex flex-col gap-2">
|
||||
<SignedIn>
|
||||
<Button variant="secondary" class="w-full">
|
||||
<A href="/dashboard">Dashboard</A>
|
||||
<A href="/dashboard">Go to Dashboard</A>
|
||||
</Button>
|
||||
</SignedIn>
|
||||
<SignedOut>
|
||||
|
||||
163
web/src/routes/(admin)/blog/[slug].tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createSignal, createEffect, Show } from "solid-js";
|
||||
import { A, Navigate, useParams } from "@solidjs/router";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
const ALL_TAGS = ["Identity Theft", "AI Safety", "Privacy", "Deepfakes", "Dark Web", "Scam Alerts", "Product News"];
|
||||
|
||||
export default function AdminBlogEdit() {
|
||||
const params = useParams();
|
||||
const [post, setPost] = createSignal<any>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [title, setTitle] = createSignal("");
|
||||
const [slug, setSlug] = createSignal("");
|
||||
const [excerpt, setExcerpt] = createSignal("");
|
||||
const [content, setContent] = createSignal("");
|
||||
const [authorName, setAuthorName] = createSignal("");
|
||||
const [coverImageUrl, setCoverImageUrl] = createSignal("");
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [published, setPublished] = createSignal(false);
|
||||
const [featured, setFeatured] = createSignal(false);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
api.admin.blogGet.query({ id: params.slug }).then(data => {
|
||||
if (data) {
|
||||
setPost(data);
|
||||
setTitle(data.title);
|
||||
setSlug(data.slug);
|
||||
setExcerpt(data.excerpt || "");
|
||||
setContent(data.content);
|
||||
setAuthorName(data.authorName || "");
|
||||
setCoverImageUrl(data.coverImageUrl || "");
|
||||
setTags(Array.isArray(data.tags) ? data.tags : []);
|
||||
setPublished(!!data.published);
|
||||
setFeatured(!!data.featured);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.admin.blogUpdate.mutate({
|
||||
id: params.slug,
|
||||
title: title() || undefined,
|
||||
slug: slug() || undefined,
|
||||
excerpt: excerpt() || undefined,
|
||||
content: content() || undefined,
|
||||
authorName: authorName() || undefined,
|
||||
coverImageUrl: coverImageUrl() || undefined,
|
||||
tags: tags().join(","),
|
||||
published: published(),
|
||||
featured: featured(),
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update post");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setTags(tags().includes(tag) ? tags().filter(t => t !== tag) : [...tags(), tag]);
|
||||
};
|
||||
|
||||
if (success()) {
|
||||
return <Navigate href="/admin/blog" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={!loading()} fallback={<p class="text-[var(--color-text-secondary)]">Loading...</p>}>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<A href="/admin/blog" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
← Back to Posts
|
||||
</A>
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">Edit Post</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} class="space-y-6 max-w-4xl">
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Title</label>
|
||||
<input type="text" value={title()} onInput={(e) => setTitle(e.currentTarget.value)} required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Slug</label>
|
||||
<input type="text" value={slug()} onInput={(e) => setSlug(e.currentTarget.value)} required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Excerpt</label>
|
||||
<textarea value={excerpt()} onInput={(e) => setExcerpt(e.currentTarget.value)} rows={2}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Author</label>
|
||||
<input type="text" value={authorName()} onInput={(e) => setAuthorName(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Cover Image URL</label>
|
||||
<input type="url" value={coverImageUrl()} onInput={(e) => setCoverImageUrl(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{ALL_TAGS.map(tag => (
|
||||
<button type="button" onClick={() => toggleTag(tag)}
|
||||
class={[
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
tags().includes(tag)
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
].join(" ")}
|
||||
>{tag}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Content (Markdown)</label>
|
||||
<textarea value={content()} onInput={(e) => setContent(e.currentTarget.value)} required rows={16}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] resize-y" />
|
||||
</div>
|
||||
<div class="flex items-center gap-6 pt-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={published()} onChange={(e) => setPublished(e.currentTarget.checked)}
|
||||
class="rounded border-[var(--color-border)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)]" />
|
||||
<span class="text-sm text-[var(--color-text-primary)]">Published</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={featured()} onChange={(e) => setFeatured(e.currentTarget.checked)}
|
||||
class="rounded border-[var(--color-border)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)]" />
|
||||
<span class="text-sm text-[var(--color-text-primary)]">Featured</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error() && (
|
||||
<div class="p-4 rounded-lg bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-400 text-sm">{error()}</div>
|
||||
)}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" disabled={saving()}
|
||||
class="px-6 py-2.5 bg-[var(--color-brand-primary)] text-white rounded-lg hover:bg-[var(--color-brand-primary)]/90 transition-colors disabled:opacity-50 text-sm font-medium">
|
||||
{saving() ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<A href="/admin/blog" class="px-6 py-2.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors text-sm">
|
||||
Cancel
|
||||
</A>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
126
web/src/routes/(admin)/blog/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { A, Navigate } from "@solidjs/router";
|
||||
import { For, Show, createSignal, createEffect } from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
export default function AdminBlog() {
|
||||
const [posts, setPosts] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [deletingId, setDeletingId] = createSignal<string | null>(null);
|
||||
const [redirect, setRedirect] = createSignal(false);
|
||||
|
||||
const loadPosts = () => {
|
||||
api.admin.blogList.query().then(setPosts).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
loadPosts();
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this post? This cannot be undone.")) return;
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await api.admin.blogDelete.mutate({ id });
|
||||
setPosts(posts().filter(p => p.id !== id));
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to delete post");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (redirect()) return <Navigate href="/admin/blog/new" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">Blog Posts</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadPosts}
|
||||
class="px-4 py-2 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] rounded-lg hover:text-[var(--color-text-primary)] transition-colors text-sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRedirect(true)}
|
||||
class="px-4 py-2 bg-[var(--color-brand-primary)] text-white rounded-lg hover:bg-[var(--color-brand-primary)]/90 transition-colors text-sm"
|
||||
>
|
||||
New Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<p class="text-[var(--color-text-secondary)]">Loading...</p>}>
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Title</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Status</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Featured</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Views</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Date</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={posts()}>
|
||||
{(post) => (
|
||||
<tr class="border-b border-[var(--color-border)] last:border-0 hover:bg-[var(--color-bg-tertiary)]">
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--color-text-primary)]">{post.title}</p>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">{post.slug}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={[
|
||||
"px-2 py-1 rounded-full text-xs font-medium",
|
||||
post.published
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
|
||||
: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
].join(" ")}>
|
||||
{post.published ? "Published" : "Draft"}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{post.featured ? "⭐" : "—"}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-[var(--color-text-secondary)]">{post.viewCount}</td>
|
||||
<td class="px-6 py-4 text-[var(--color-text-secondary)]">
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : "—"}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<A
|
||||
href={`/admin/blog/${post.id}`}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:text-[var(--color-brand-accent)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</A>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(post.id)}
|
||||
disabled={deletingId() === post.id}
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingId() === post.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={posts().length === 0}>
|
||||
<p class="text-[var(--color-text-secondary)] py-8 text-center">No posts yet</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
web/src/routes/(admin)/blog/new.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createSignal, createEffect } from "solid-js";
|
||||
import { A, Navigate } from "@solidjs/router";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
const ALL_TAGS = ["Identity Theft", "AI Safety", "Privacy", "Deepfakes", "Dark Web", "Scam Alerts", "Product News"];
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
export default function AdminBlogNew() {
|
||||
const [title, setTitle] = createSignal("");
|
||||
const [slug, setSlug] = createSignal("");
|
||||
const [excerpt, setExcerpt] = createSignal("");
|
||||
const [content, setContent] = createSignal("");
|
||||
const [authorName, setAuthorName] = createSignal("Kordant Security Team");
|
||||
const [coverImageUrl, setCoverImageUrl] = createSignal("");
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [published, setPublished] = createSignal(false);
|
||||
const [featured, setFeatured] = createSignal(false);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [success, setSuccess] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
const t = title();
|
||||
if (t && !slug()) {
|
||||
setSlug(slugify(t));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.admin.blogCreate.mutate({
|
||||
title: title(),
|
||||
slug: slug(),
|
||||
excerpt: excerpt() || undefined,
|
||||
content: content(),
|
||||
authorName: authorName() || undefined,
|
||||
coverImageUrl: coverImageUrl() || undefined,
|
||||
tags: tags().join(","),
|
||||
published: published(),
|
||||
featured: featured(),
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create post");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setTags(tags().includes(tag) ? tags().filter(t => t !== tag) : [...tags(), tag]);
|
||||
};
|
||||
|
||||
if (success()) {
|
||||
return <Navigate href="/admin/blog" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<A href="/admin/blog" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
← Back to Posts
|
||||
</A>
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">New Blog Post</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} class="space-y-6 max-w-4xl">
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||
required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]"
|
||||
placeholder="Enter post title..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug()}
|
||||
onInput={(e) => setSlug(e.currentTarget.value)}
|
||||
required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]"
|
||||
placeholder="auto-generated-from-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Excerpt</label>
|
||||
<textarea
|
||||
value={excerpt()}
|
||||
onInput={(e) => setExcerpt(e.currentTarget.value)}
|
||||
rows={2}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] resize-none"
|
||||
placeholder="Brief summary for the blog listing..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authorName()}
|
||||
onInput={(e) => setAuthorName(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]"
|
||||
placeholder="Author name..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Cover Image URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={coverImageUrl()}
|
||||
onInput={(e) => setCoverImageUrl(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{ALL_TAGS.map(tag => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
class={[
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
tags().includes(tag)
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Content (Markdown)</label>
|
||||
<textarea
|
||||
value={content()}
|
||||
onInput={(e) => setContent(e.currentTarget.value)}
|
||||
required
|
||||
rows={16}
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)] text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] resize-y"
|
||||
placeholder="# Title Write your content here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 pt-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published()}
|
||||
onChange={(e) => setPublished(e.currentTarget.checked)}
|
||||
class="rounded border-[var(--color-border)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--color-text-primary)]">Published</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={featured()}
|
||||
onChange={(e) => setFeatured(e.currentTarget.checked)}
|
||||
class="rounded border-[var(--color-border)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--color-text-primary)]">Featured</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error() && (
|
||||
<div class="p-4 rounded-lg bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-400 text-sm">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving()}
|
||||
class="px-6 py-2.5 bg-[var(--color-brand-primary)] text-white rounded-lg hover:bg-[var(--color-brand-primary)]/90 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
{saving() ? "Creating..." : "Create Post"}
|
||||
</button>
|
||||
<A href="/admin/blog" class="px-6 py-2.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors text-sm">
|
||||
Cancel
|
||||
</A>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
web/src/routes/(admin)/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { For, Show, createSignal, createEffect } from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
function StatCard(props: { label: string; value: string | number; icon: string }) {
|
||||
return (
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">{props.label}</p>
|
||||
<p class="text-3xl font-bold text-[var(--color-text-primary)] mt-2">{props.value}</p>
|
||||
</div>
|
||||
<span class="text-2xl">{props.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = createSignal<any>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
createEffect(() => {
|
||||
api.admin.stats.query().then(setStats).finally(() => setLoading(false));
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Dashboard</h2>
|
||||
|
||||
<Show when={!loading()} fallback={<p class="text-[var(--color-text-secondary)]">Loading...</p>}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<StatCard label="Total Users" value={stats()?.userCount ?? 0} icon="👥" />
|
||||
<StatCard label="Blog Posts" value={stats()?.postCount ?? 0} icon="📝" />
|
||||
<StatCard label="Total Views" value={stats()?.totalViews ?? 0} icon="👁️" />
|
||||
</div>
|
||||
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)]">
|
||||
<div class="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)]">Recent Posts</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<For each={stats()?.recentPosts ?? []}>
|
||||
{(post) => (
|
||||
<div class="flex items-center justify-between py-3 border-b border-[var(--color-border)] last:border-0">
|
||||
<span class="text-[var(--color-text-primary)]">{post.title}</span>
|
||||
<span class="text-sm text-[var(--color-text-secondary)]">
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={(!stats()?.recentPosts || stats()?.recentPosts.length === 0)}>
|
||||
<p class="text-[var(--color-text-secondary)] py-4">No posts yet</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
web/src/routes/(admin)/layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { For, Show, createSignal, createEffect } from "solid-js";
|
||||
import { useAuth } from "clerk-solidjs";
|
||||
|
||||
const adminNavItems = [
|
||||
{ label: "Dashboard", href: "/admin" },
|
||||
{ label: "Blog Posts", href: "/admin/blog" },
|
||||
{ label: "Services", href: "/admin/services" },
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
];
|
||||
|
||||
export function AdminLayout(props: { children: () => any }) {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const [isAuthorized, setIsAuthorized] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (auth.isLoaded() && !auth.isSignedIn()) {
|
||||
window.location.href = "/login";
|
||||
} else if (auth.isLoaded() && auth.isSignedIn()) {
|
||||
setIsAuthorized(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={isAuthorized()}>
|
||||
<div class="min-h-screen bg-[var(--color-bg)] flex">
|
||||
<aside class="w-64 bg-[var(--color-bg-secondary)] border-r border-[var(--color-border)] flex flex-col">
|
||||
<div class="p-6 border-b border-[var(--color-border)]">
|
||||
<h1 class="text-xl font-bold text-[var(--color-text-primary)]">Admin Panel</h1>
|
||||
</div>
|
||||
<nav class="flex-1 p-4">
|
||||
<ul class="space-y-1">
|
||||
<For each={adminNavItems}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<A
|
||||
href={item.href}
|
||||
class={[
|
||||
"block px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
location.pathname === item.href
|
||||
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</A>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-[var(--color-border)]">
|
||||
<A
|
||||
href="/"
|
||||
class="block px-4 py-2 rounded-lg text-sm font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
← Back to Site
|
||||
</A>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="p-8 max-w-6xl">
|
||||
{props.children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
116
web/src/routes/(admin)/services.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { For, createSignal, createEffect } from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
status: "operational" | "degraded" | "down";
|
||||
uptime: string;
|
||||
incident: string | null;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
// Placeholder services data — replace with real health checks when available
|
||||
const services: Service[] = [
|
||||
{ name: "DarkWatch", status: "operational", uptime: "99.98%", incident: null, lastChecked: "2 min ago" },
|
||||
{ name: "VoicePrint", status: "operational", uptime: "99.95%", incident: null, lastChecked: "1 min ago" },
|
||||
{ name: "SpamShield", status: "operational", uptime: "99.97%", incident: null, lastChecked: "3 min ago" },
|
||||
{ name: "HomeTitle", status: "degraded", uptime: "98.50%", incident: "Elevated response times on title lookup API", lastChecked: "5 min ago" },
|
||||
{ name: "RemoveBrokers", status: "operational", uptime: "99.90%", incident: null, lastChecked: "4 min ago" },
|
||||
{ name: "Database", status: "operational", uptime: "100.00%", incident: null, lastChecked: "1 min ago" },
|
||||
{ name: "Clerk Auth", status: "operational", uptime: "99.99%", incident: null, lastChecked: "1 min ago" },
|
||||
];
|
||||
|
||||
function StatusBadge(props: { status: Service["status"] }) {
|
||||
const config = {
|
||||
operational: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
|
||||
degraded: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
down: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400",
|
||||
};
|
||||
return (
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${config[props.status]}`}>
|
||||
{props.status.charAt(0).toUpperCase() + props.status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminServices() {
|
||||
const [refreshing, setRefreshing] = createSignal(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
// Simulate refresh — replace with real health check API call
|
||||
setTimeout(() => setRefreshing(false), 800);
|
||||
};
|
||||
|
||||
const stats = () => {
|
||||
const total = services.length;
|
||||
const operational = services.filter(s => s.status === "operational").length;
|
||||
const degraded = services.filter(s => s.status === "degraded").length;
|
||||
const down = services.filter(s => s.status === "down").length;
|
||||
return { total, operational, degraded, down };
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">Services</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing()}
|
||||
class="px-4 py-2 bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] rounded-lg hover:text-[var(--color-text-primary)] transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{refreshing() ? "Refreshing..." : "Refresh Status"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Total Services", value: stats().total, color: "text-[var(--color-text-primary)]" },
|
||||
{ label: "Operational", value: stats().operational, color: "text-green-600 dark:text-green-400" },
|
||||
{ label: "Degraded", value: stats().degraded, color: "text-yellow-600 dark:text-yellow-400" },
|
||||
{ label: "Down", value: stats().down, color: "text-red-600 dark:text-red-400" },
|
||||
].map(stat => (
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] p-4">
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-1">{stat.label}</p>
|
||||
<p class={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Services Table */}
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Service</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Status</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Uptime</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Incident</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Last Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={services}>
|
||||
{(service) => (
|
||||
<tr class="border-b border-[var(--color-border)] last:border-0 hover:bg-[var(--color-bg-tertiary)]">
|
||||
<td class="px-6 py-4 font-medium text-[var(--color-text-primary)]">{service.name}</td>
|
||||
<td class="px-6 py-4"><StatusBadge status={service.status} /></td>
|
||||
<td class="px-6 py-4 text-[var(--color-text-secondary)]">{service.uptime}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
{service.incident
|
||||
? <span class="text-yellow-700 dark:text-yellow-400">{service.incident}</span>
|
||||
: <span class="text-[var(--color-text-tertiary)]">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-[var(--color-text-secondary)]">{service.lastChecked}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
web/src/routes/(admin)/users.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { For, Show, createSignal, createEffect } from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [users, setUsers] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
createEffect(() => {
|
||||
api.admin.userList.query().then(setUsers).finally(() => setLoading(false));
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
api.admin.userList.query().then(setUsers);
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: string) => {
|
||||
await api.admin.userUpdateRole.mutate({ id: userId, role: newRole });
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">Users</h2>
|
||||
<button
|
||||
onclick={refresh}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<p class="text-[var(--color-text-secondary)]">Loading...</p>}>
|
||||
<div class="bg-[var(--color-bg-secondary)] rounded-xl border border-[var(--color-border)] overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">User</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Role</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Joined</th>
|
||||
<th class="text-left px-6 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<tr class="border-b border-[var(--color-border)] last:border-0 hover:bg-[var(--color-bg-tertiary)]">
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--color-text-primary)]">{user.name || "—"}</p>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={[
|
||||
"px-2 py-1 rounded-full text-xs font-medium",
|
||||
user.role === "admin"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
].join(" ")}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-[var(--color-text-secondary)]">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onchange={(e) => handleRoleChange(user.id, e.currentTarget.value)}
|
||||
class="px-2 py-1 text-sm rounded border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-primary)]"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="support">Support</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={users().length === 0}>
|
||||
<p class="text-[var(--color-text-secondary)] py-8 text-center">No users found</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +1,74 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { createSignal, For, Show, createMemo, Suspense } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Button, Card } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
date: string;
|
||||
readingTime: string;
|
||||
coverImage: string;
|
||||
tags: string[];
|
||||
const POSTS_PER_PAGE = 6;
|
||||
|
||||
function readingTime(content: string): string {
|
||||
const words = content.split(/\s+/).length;
|
||||
const mins = Math.max(1, Math.ceil(words / 200));
|
||||
return `${mins} min read`;
|
||||
}
|
||||
|
||||
const allTags = ["Identity Theft", "AI Safety", "Privacy", "Deepfakes", "Dark Web", "Scam Alerts", "Product News"];
|
||||
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
slug: "ai-scam-trends-2026",
|
||||
title: "AI Scam Trends to Watch in 2026",
|
||||
excerpt: "As AI technology advances, scammers are finding new ways to exploit it. Here are the top threats to watch and how to protect yourself.",
|
||||
author: "Sarah Chen",
|
||||
date: "May 15, 2026",
|
||||
readingTime: "5 min read",
|
||||
coverImage: "",
|
||||
tags: ["AI Safety", "Scam Alerts", "Deepfakes"],
|
||||
},
|
||||
{
|
||||
slug: "dark-web-monitoring-guide",
|
||||
title: "The Complete Guide to Dark Web Monitoring",
|
||||
excerpt: "Learn how dark web monitoring works, what data gets exposed, and how Kordant keeps your information safe from cybercriminals.",
|
||||
author: "Mike Reynolds",
|
||||
date: "May 10, 2026",
|
||||
readingTime: "8 min read",
|
||||
coverImage: "",
|
||||
tags: ["Dark Web", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "protecting-family-identity",
|
||||
title: "Protecting Your Family's Digital Identity",
|
||||
excerpt: "Your family's personal data is at risk. Discover the steps you can take to protect everyone in your household from identity theft.",
|
||||
author: "Emily Torres",
|
||||
date: "May 5, 2026",
|
||||
readingTime: "6 min read",
|
||||
coverImage: "",
|
||||
tags: ["Identity Theft", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "deepfake-voice-scams",
|
||||
title: "Deepfake Voice Scams: How They Work and How to Spot Them",
|
||||
excerpt: "AI-generated voice clones are being used to impersonate loved ones. Here's what to listen for and how Kordant's VoicePrint can help.",
|
||||
author: "Sarah Chen",
|
||||
date: "April 28, 2026",
|
||||
readingTime: "7 min read",
|
||||
coverImage: "",
|
||||
tags: ["Deepfakes", "AI Safety", "Scam Alerts"],
|
||||
},
|
||||
{
|
||||
slug: "what-is-data-broker",
|
||||
title: "What Is a Data Broker and How to Remove Your Information",
|
||||
excerpt: "Data brokers collect and sell your personal information. Find out how to opt out and reclaim your privacy with RemoveBrokers.",
|
||||
author: "Alex Kim",
|
||||
date: "April 20, 2026",
|
||||
readingTime: "4 min read",
|
||||
coverImage: "",
|
||||
tags: ["Privacy", "Identity Theft"],
|
||||
},
|
||||
{
|
||||
slug: "kordant-product-update-may-2026",
|
||||
title: "Kordant Product Update — May 2026",
|
||||
excerpt: "New features including improved VoicePrint detection, expanded dark web monitoring, and a redesigned dashboard experience.",
|
||||
author: "Product Team",
|
||||
date: "April 15, 2026",
|
||||
readingTime: "3 min read",
|
||||
coverImage: "",
|
||||
tags: ["Product News"],
|
||||
},
|
||||
];
|
||||
|
||||
const POSTS_PER_PAGE = 4;
|
||||
|
||||
export default function BlogPage() {
|
||||
const [selectedTag, setSelectedTag] = createSignal<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = createSignal(POSTS_PER_PAGE);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
const filtered = () => {
|
||||
// Fetch all published posts
|
||||
const allPosts = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res => {
|
||||
setLoading(false);
|
||||
return res.posts;
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch tags
|
||||
const tagList = createMemo(() => api.blog.tags.query());
|
||||
|
||||
// Fetch featured post
|
||||
const featuredPost = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res =>
|
||||
res.posts.find((p: any) => p.featured) ?? null
|
||||
);
|
||||
});
|
||||
|
||||
// Filtered + visible posts
|
||||
const visible = createMemo(() => {
|
||||
const posts = allPosts();
|
||||
if (!posts) return [];
|
||||
const tag = selectedTag();
|
||||
if (!tag) return blogPosts;
|
||||
return blogPosts.filter((p) => p.tags.includes(tag));
|
||||
};
|
||||
const filtered = tag
|
||||
? posts.filter((p: any) => {
|
||||
const tags = p.tags as string[];
|
||||
return tags?.includes(tag);
|
||||
})
|
||||
: posts;
|
||||
return filtered.slice(0, visibleCount());
|
||||
});
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const posts = allPosts();
|
||||
if (!posts) return [];
|
||||
const tag = selectedTag();
|
||||
if (!tag) return posts;
|
||||
return posts.filter((p: any) => {
|
||||
const tags = p.tags as string[];
|
||||
return tags?.includes(tag);
|
||||
});
|
||||
});
|
||||
|
||||
const visible = () => filtered().slice(0, visibleCount());
|
||||
const hasMore = () => visibleCount() < filtered().length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>Kordant Blog — AI-Powered Identity Protection</Title>
|
||||
|
||||
{/* Hero */}
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
@@ -114,80 +83,126 @@ export default function BlogPage() {
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="pb-20">
|
||||
<PageContainer>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(null); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
!selectedTag()
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<For each={allTags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(tag); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
selectedTag() === tag
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={visible()}>
|
||||
{(post) => (
|
||||
<A href={`/blog/${post.slug}`}>
|
||||
<Card class="h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="h-40 bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg mb-4 flex items-center justify-center text-[var(--color-text-tertiary)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" class="opacity-40">
|
||||
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 12h24M12 6v20" stroke="currentColor" stroke-width="2"/>
|
||||
{/* Featured Post */}
|
||||
<Suspense>
|
||||
<Show when={featuredPost()}>
|
||||
{(fp) => (
|
||||
<section class="py-10">
|
||||
<PageContainer>
|
||||
<A href={`/blog/${fp().slug}`}>
|
||||
<Card class="flex flex-col md:flex-row gap-6 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="md:w-64 h-40 md:h-auto bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg flex-shrink-0 flex items-center justify-center">
|
||||
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" class="text-[var(--color-brand-primary)] opacity-40">
|
||||
<path d="M14 2L2 8v8c0 7 6 12 12 13 6-1 12-6 12-13V8L14 2z" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<For each={post.tags}>
|
||||
{(tag) => <Badge variant="default">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4 line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)] mt-auto">
|
||||
<span>{post.author}</span>
|
||||
<span>{post.date} · {post.readingTime}</span>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Badge variant="info" class="text-xs">Featured</Badge>
|
||||
<span class="text-xs text-[var(--color-text-tertiary)]">
|
||||
{fp().publishedAt ? new Date(fp().publishedAt).toLocaleDateString() : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)] mb-2">{fp().title}</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2">{fp().excerpt}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
|
||||
<Show when={hasMore()}>
|
||||
<div class="text-center mt-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setVisibleCount((c) => c + POSTS_PER_PAGE)}
|
||||
{/* Tag Filters + Posts Grid */}
|
||||
<section class="pb-20">
|
||||
<PageContainer>
|
||||
<Suspense fallback={<div class="flex flex-wrap gap-2 mb-10" />}>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(null); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
!selectedTag()
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
Load More Posts
|
||||
</Button>
|
||||
All
|
||||
</button>
|
||||
<For each={tagList()}>
|
||||
{({ tag, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(tag); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
selectedTag() === tag
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
{tag} ({count})
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={visible()}>
|
||||
{(post: any) => (
|
||||
<A href={`/blog/${post.slug}`}>
|
||||
<Card class="h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="h-40 bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg mb-4 flex items-center justify-center text-[var(--color-text-tertiary)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" class="opacity-40">
|
||||
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 12h24M12 6v20" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<For each={post.tags as string[]}>
|
||||
{(tag) => <Badge variant="default">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4 line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)] mt-auto">
|
||||
<span>{post.authorName || "Kordant"}</span>
|
||||
<span>
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : ""}
|
||||
{" · "}
|
||||
{readingTime(post.content)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={visible().length === 0}>
|
||||
<div class="text-center py-16">
|
||||
<p class="text-[var(--color-text-secondary)] text-lg">No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasMore()}>
|
||||
<div class="text-center mt-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setVisibleCount((c) => c + POSTS_PER_PAGE)}
|
||||
>
|
||||
Load More Posts
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
@@ -1,177 +1,17 @@
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import { For, Show, createMemo, Suspense } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Card, Button } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
import { api } from "~/lib/api";
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
authorRole: string;
|
||||
date: string;
|
||||
readingTime: string;
|
||||
coverImage: string;
|
||||
tags: string[];
|
||||
function readingTime(content: string): string {
|
||||
const words = content.split(/\s+/).length;
|
||||
const mins = Math.max(1, Math.ceil(words / 200));
|
||||
return `${mins} min read`;
|
||||
}
|
||||
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
slug: "ai-scam-trends-2026",
|
||||
title: "AI Scam Trends to Watch in 2026",
|
||||
excerpt: "As AI technology advances, scammers are finding new ways to exploit it.",
|
||||
content: `## The Rise of AI-Powered Scams
|
||||
|
||||
Artificial intelligence has become a double-edged sword. While it powers innovation across industries, it also arms bad actors with sophisticated tools for deception.
|
||||
|
||||
### Voice Cloning Scams
|
||||
|
||||
One of the most alarming trends is the use of AI voice cloning. Scammers need only a few seconds of audio—often scraped from social media videos—to create convincing voice replicas. These are used to impersonate family members in distress, requesting urgent money transfers.
|
||||
|
||||
### Deepfake Video Conferencing
|
||||
|
||||
In 2025, we saw the first wave of deepfake video calls used in corporate impersonation scams. Attackers use real-time face-swapping technology to pose as executives during Zoom calls, authorizing fraudulent wire transfers.
|
||||
|
||||
### Automated Phishing at Scale
|
||||
|
||||
AI-generated phishing emails are now nearly indistinguishable from legitimate correspondence. Language models craft personalized messages that reference real events, contacts, and context, dramatically increasing click-through rates.
|
||||
|
||||
## How to Protect Yourself
|
||||
|
||||
1. **Verify voice requests** — If someone calls asking for money or sensitive information, hang up and call them back on a trusted number.
|
||||
2. **Use a safe word** — Establish a family safe word that can be used to verify identity in suspicious situations.
|
||||
3. **Enable Kordant VoicePrint** — Our AI detects voice clones by analyzing acoustic fingerprints that deepfakes cannot replicate.
|
||||
4. **Stay skeptical of urgency** — Scammers create false urgency to bypass your critical thinking.
|
||||
|
||||
## The Kordant Advantage
|
||||
|
||||
Kordant's multi-layered protection uses machine learning models trained on millions of scam attempts to identify emerging threats before they reach you. Our DarkWatch service continuously scans the dark web for exposed credentials, while VoicePrint protects against audio deepfakes.`,
|
||||
author: "Sarah Chen",
|
||||
authorRole: "Security Researcher",
|
||||
date: "May 15, 2026",
|
||||
readingTime: "5 min read",
|
||||
coverImage: "",
|
||||
tags: ["AI Safety", "Scam Alerts", "Deepfakes"],
|
||||
},
|
||||
{
|
||||
slug: "dark-web-monitoring-guide",
|
||||
title: "The Complete Guide to Dark Web Monitoring",
|
||||
excerpt: "Learn how dark web monitoring works and how Kordant keeps your information safe.",
|
||||
content: `## What Is Dark Web Monitoring?
|
||||
|
||||
The dark web is a hidden part of the internet where cybercriminals trade stolen data. Dark web monitoring services scan these hidden marketplaces, forums, and chat channels for your personal information.
|
||||
|
||||
### What Data Gets Exposed
|
||||
|
||||
When data breaches occur, the following types of information are commonly stolen and sold:
|
||||
|
||||
- **Email addresses and passwords** — The most common credential type traded on dark web markets
|
||||
- **Social Security numbers** — Often used for identity theft and tax fraud
|
||||
- **Credit card details** — Sold in bulk with CVV codes and billing addresses
|
||||
- **Medical records** — Highly valuable on the black market for insurance fraud
|
||||
- **Phone numbers** — Used for SIM swapping and phishing attacks`,
|
||||
author: "Mike Reynolds",
|
||||
authorRole: "Cybersecurity Analyst",
|
||||
date: "May 10, 2026",
|
||||
readingTime: "8 min read",
|
||||
coverImage: "",
|
||||
tags: ["Dark Web", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "protecting-family-identity",
|
||||
title: "Protecting Your Family's Digital Identity",
|
||||
excerpt: "Discover steps to protect everyone in your household from identity theft.",
|
||||
content: `## Why Family Identity Protection Matters
|
||||
|
||||
Identity thieves don't discriminate. Children, elderly parents, and everyone in between are potential targets. In fact, child identity theft is particularly insidious because it often goes undetected for years.
|
||||
|
||||
### Common Family Identity Threats
|
||||
|
||||
- **Child identity theft** — Stolen SSNs used to open credit lines, often discovered when the child applies for college loans
|
||||
- **Elder financial exploitation** — Scammers target seniors with tech support scams, grandparent scams, and Medicare fraud
|
||||
- **Family account sharing** — Shared passwords across family streaming services can expose personal data`,
|
||||
author: "Emily Torres",
|
||||
authorRole: "Privacy Advocate",
|
||||
date: "May 5, 2026",
|
||||
readingTime: "6 min read",
|
||||
coverImage: "",
|
||||
tags: ["Identity Theft", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "deepfake-voice-scams",
|
||||
title: "Deepfake Voice Scams: How They Work and How to Spot Them",
|
||||
excerpt: "AI-generated voice clones are being used to impersonate loved ones.",
|
||||
content: `## The Mechanics of Voice Cloning
|
||||
|
||||
Modern AI voice cloning requires surprisingly little source material. Just 30 seconds of audio—from a voicemail, social media video, or recorded phone call—is enough to generate a convincing voice clone.
|
||||
|
||||
### Real Cases
|
||||
|
||||
In 2024, a family in Arizona received a frantic call from what sounded like their daughter, claiming she had been kidnapped and demanding ransom. The voice was so convincing that the family almost transferred $50,000 before reaching the real daughter.
|
||||
|
||||
### How VoicePrint Detects Clones
|
||||
|
||||
VoicePrint analyzes over 200 acoustic features that are nearly impossible for AI to replicate perfectly, including micro-tremors, breathing patterns, and formant transitions unique to each person's vocal tract.`,
|
||||
author: "Sarah Chen",
|
||||
authorRole: "Security Researcher",
|
||||
date: "April 28, 2026",
|
||||
readingTime: "7 min read",
|
||||
coverImage: "",
|
||||
tags: ["Deepfakes", "AI Safety", "Scam Alerts"],
|
||||
},
|
||||
{
|
||||
slug: "what-is-data-broker",
|
||||
title: "What Is a Data Broker and How to Remove Your Information",
|
||||
excerpt: "Data brokers collect and sell your personal information.",
|
||||
content: `## Understanding Data Brokers
|
||||
|
||||
Data brokers are companies that collect personal information from various sources—public records, purchasing history, social media activity, and website tracking—then compile and sell this data to third parties.
|
||||
|
||||
### Why It Matters
|
||||
|
||||
Your data broker profile can include your home address, phone number, email address, income level, purchasing habits, political affiliation, and even health-related interests. This information can be used for targeted scams, identity theft, or unwanted marketing.
|
||||
|
||||
### How RemoveBrokers Helps
|
||||
|
||||
Kordant's RemoveBrokers service automates the opt-out process for hundreds of data broker sites, sending removal requests on your behalf and verifying that your information has been deleted.`,
|
||||
author: "Alex Kim",
|
||||
authorRole: "Data Privacy Specialist",
|
||||
date: "April 20, 2026",
|
||||
readingTime: "4 min read",
|
||||
coverImage: "",
|
||||
tags: ["Privacy", "Identity Theft"],
|
||||
},
|
||||
{
|
||||
slug: "kordant-product-update-may-2026",
|
||||
title: "Kordant Product Update — May 2026",
|
||||
excerpt: "New features including improved VoicePrint detection and redesigned dashboard.",
|
||||
content: `## What's New in Kordant
|
||||
|
||||
We're excited to announce our May 2026 product update, packed with new features and improvements based on your feedback.
|
||||
|
||||
### Enhanced VoicePrint Detection
|
||||
|
||||
Our voice clone detection model has been retrained on a dataset 3x larger than before, improving detection accuracy to 99.7% while reducing false positives by 40%.
|
||||
|
||||
### Redesigned Dashboard
|
||||
|
||||
The dashboard has been completely redesigned for faster access to critical information. Key metrics are now displayed at a glance, and each service has its own dedicated section with detailed analytics.
|
||||
|
||||
### Expanded Dark Web Coverage
|
||||
|
||||
We've added monitoring for 50 additional dark web forums and marketplaces, bringing our total coverage to over 200 sources.`,
|
||||
author: "Product Team",
|
||||
authorRole: "Kordant",
|
||||
date: "April 15, 2026",
|
||||
readingTime: "3 min read",
|
||||
coverImage: "",
|
||||
tags: ["Product News"],
|
||||
},
|
||||
];
|
||||
|
||||
function contentToHtml(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
let html = "";
|
||||
@@ -205,24 +45,13 @@ function contentToHtml(markdown: string): string {
|
||||
return html;
|
||||
}
|
||||
|
||||
function getRelatedPosts(current: BlogPost, all: BlogPost[], count: number): BlogPost[] {
|
||||
const shared = all
|
||||
.filter((p) => p.slug !== current.slug)
|
||||
.map((p) => ({
|
||||
post: p,
|
||||
sharedTags: p.tags.filter((t) => current.tags.includes(t)).length,
|
||||
}))
|
||||
.filter((p) => p.sharedTags > 0)
|
||||
.sort((a, b) => b.sharedTags - a.sharedTags)
|
||||
.slice(0, count);
|
||||
return shared.map((s) => s.post);
|
||||
}
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const params = useParams();
|
||||
const post = createMemo(() => blogPosts.find((p) => p.slug === params.slug));
|
||||
const data = createMemo(() => api.blog.bySlug.query({ slug: params.slug }));
|
||||
|
||||
const post = createMemo(() => data()?.post ?? null);
|
||||
const related = createMemo(() => data()?.related ?? []);
|
||||
const contentHtml = createMemo(() => post() ? contentToHtml(post()!.content) : "");
|
||||
const relatedPosts = createMemo(() => post() ? getRelatedPosts(post()!, blogPosts, 2) : []);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -242,122 +71,124 @@ export default function BlogPostPage() {
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<main>
|
||||
<Title>{post()!.title} — Kordant Blog</Title>
|
||||
{(p) => (
|
||||
<main>
|
||||
<Title>{p().title} — Kordant Blog</Title>
|
||||
|
||||
<article>
|
||||
<section class="relative py-16 md:py-20 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<A href="/blog" class="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] mb-6 transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Blog
|
||||
</A>
|
||||
<article>
|
||||
<section class="relative py-16 md:py-20 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<A href="/blog" class="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] mb-6 transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Blog
|
||||
</A>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<For each={post()!.tags}>
|
||||
{(tag) => <Badge variant="info">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4 max-w-3xl">
|
||||
{post()!.title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-[var(--color-text-tertiary)] mb-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xs font-bold">
|
||||
{post()!.author.split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">{post()!.author}</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">{post()!.authorRole}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<For each={p().tags as string[]}>
|
||||
{(tag) => <Badge variant="info">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{post()!.date}</span>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{post()!.readingTime}</span>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="pb-16">
|
||||
<PageContainer>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-10">
|
||||
<div class="prose-custom" innerHTML={contentHtml()} />
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4 max-w-3xl">
|
||||
{p().title}
|
||||
</h1>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xl font-bold mx-auto mb-3">
|
||||
{post()!.author.split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">{post()!.author}</h3>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)] mb-3">{post()!.authorRole}</p>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Security researcher and writer covering digital identity protection and AI safety.</p>
|
||||
<div class="flex items-center gap-4 text-sm text-[var(--color-text-tertiary)] mb-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xs font-bold">
|
||||
{(p().authorName || "K").split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Share this article</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on Twitter"
|
||||
onClick={() => window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(post()!.title)}&url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on LinkedIn"
|
||||
onClick={() => window.open(`https://linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Copy link"
|
||||
onClick={() => navigator.clipboard.writeText(window.location.href)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">{p().authorName || "Kordant"}</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">Security Team</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{p().publishedAt ? new Date(p().publishedAt).toLocaleDateString() : ""}</span>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{readingTime(p().content)}</span>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="pb-16">
|
||||
<PageContainer>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-10">
|
||||
<div class="prose-custom" innerHTML={contentHtml()} />
|
||||
|
||||
<aside class="space-y-6">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xl font-bold mx-auto mb-3">
|
||||
{(p().authorName || "K").split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">{p().authorName || "Kordant"}</h3>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)] mb-3">Security Team</p>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Research and insights on digital identity protection and AI safety.</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Show when={relatedPosts().length > 0}>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Related Posts</h3>
|
||||
<div class="space-y-3">
|
||||
<For each={relatedPosts()}>
|
||||
{(rp) => (
|
||||
<A href={`/blog/${rp.slug}`}>
|
||||
<Card class="hover:shadow-md transition-shadow">
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)] mb-1">{rp.title}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={rp.tags}>
|
||||
{(tag) => <Badge>{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Share this article</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on Twitter"
|
||||
onClick={() => window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(p().title)}&url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on LinkedIn"
|
||||
onClick={() => window.open(`https://linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Copy link"
|
||||
onClick={() => navigator.clipboard.writeText(window.location.href)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</aside>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<Show when={related().length > 0}>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Related Posts</h3>
|
||||
<div class="space-y-3">
|
||||
<For each={related()}>
|
||||
{(rp: any) => (
|
||||
<A href={`/blog/${rp.slug}`}>
|
||||
<Card class="hover:shadow-md transition-shadow">
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)] mb-1">{rp.title}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={rp.tags as string[]}>
|
||||
{(tag) => <Badge>{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</aside>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
218
web/src/routes/features.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { For } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A } from "@solidjs/router";
|
||||
import { Badge, Card } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface FeatureSection {
|
||||
title: string;
|
||||
description: string;
|
||||
iconD: string;
|
||||
benefits: string[];
|
||||
link: string;
|
||||
linkText: string;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
const features: FeatureSection[] = [
|
||||
{
|
||||
title: "DarkWatch",
|
||||
description: "Continuous dark web monitoring that scans forums, marketplaces, and data dumps for your exposed credentials and personal information.",
|
||||
iconD: "M10 2L2 6v6c0 4.4 3.6 8.4 8 9 4.4-.6 8-4.6 8-9V6l-8-4zm0 1.7L16 7.5v4.5c0 3.7-2.6 7-6 8-3.4-1-6-4.3-6-8V7.5l6-3.8z",
|
||||
benefits: [
|
||||
"200+ dark web sources monitored in real-time",
|
||||
"Instant alerts when your data appears online",
|
||||
"Detailed breach reports with remediation steps",
|
||||
"Credential leak detection across all accounts",
|
||||
"Family-wide monitoring with centralized alerts",
|
||||
],
|
||||
link: "/darkwatch",
|
||||
linkText: "Open DarkWatch",
|
||||
badge: "Dark Web",
|
||||
},
|
||||
{
|
||||
title: "VoicePrint",
|
||||
description: "AI-powered voice clone detection that analyzes acoustic fingerprints to identify deepfake voices before they can harm you or your family.",
|
||||
iconD: "M9 2h2v3H9V2zM6 6h8v1.5H6V6zm-1 3h10v1.5H5V9zm0 3h10v1.5H5V12z",
|
||||
benefits: [
|
||||
"99.7% accuracy in detecting AI voice clones",
|
||||
"Real-time analysis of incoming calls",
|
||||
"200+ acoustic feature comparison",
|
||||
"Micro-tremor and breathing pattern detection",
|
||||
"Works with any phone call or voice message",
|
||||
],
|
||||
link: "/voiceprint",
|
||||
linkText: "Open VoicePrint",
|
||||
badge: "AI Safety",
|
||||
},
|
||||
{
|
||||
title: "SpamShield",
|
||||
description: "Intelligent spam and scam call blocking that uses machine learning to identify and filter malicious calls before they reach you.",
|
||||
iconD: "M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2zm0 2.1L5 7.2v3.3c0 4.2 2.8 7.8 5 8.6 2.2-.8 5-4.4 5-8.6V7.2l-5-3.1z",
|
||||
benefits: [
|
||||
"Blocks 99.2% of spam and scam calls",
|
||||
"Real-time call risk scoring",
|
||||
"Customizable blocking rules",
|
||||
"Detailed call analytics and history",
|
||||
"Family-wide protection with shared rules",
|
||||
],
|
||||
link: "/spamshield",
|
||||
linkText: "Open SpamShield",
|
||||
badge: "Call Protection",
|
||||
},
|
||||
{
|
||||
title: "HomeTitle",
|
||||
description: "Property fraud detection that monitors your home title for unauthorized changes, ensuring your biggest asset stays protected.",
|
||||
iconD: "M10 3L3 8v9h5v-5h4v5h5V8l-7-5zm0 2.5L14 9H6l4-3.5z",
|
||||
benefits: [
|
||||
"Instant alerts on title changes or transfers",
|
||||
"Monitoring for fraudulent mortgage applications",
|
||||
"Property boundary and ownership verification",
|
||||
"Historical title change tracking",
|
||||
"Legal documentation and support resources",
|
||||
],
|
||||
link: "/hometitle",
|
||||
linkText: "Open HomeTitle",
|
||||
badge: "Property",
|
||||
},
|
||||
{
|
||||
title: "RemoveBrokers",
|
||||
description: "Automated data broker removal that sends opt-out requests to 200+ data broker sites and verifies your information has been deleted.",
|
||||
iconD: "M7 3h6v2H7V3zm-1 4h8l1 1v1.5H5V8l1-1zm-1 4h10v7H5v-7zm2 2v3h6v-3H7z",
|
||||
benefits: [
|
||||
"200+ data broker sites covered",
|
||||
"Automated opt-out request submission",
|
||||
"Removal verification and re-scanning",
|
||||
"New listing detection and removal",
|
||||
"Family-wide data reclamation",
|
||||
],
|
||||
link: "/removebrokers",
|
||||
linkText: "Open RemoveBrokers",
|
||||
badge: "Privacy",
|
||||
},
|
||||
{
|
||||
title: "Family Plans",
|
||||
description: "Unified family protection that lets you monitor and manage security for all household members from a single, intuitive dashboard.",
|
||||
iconD: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-2h2v2zm0-4h-2V7h2v6zm4 4h-2v-2h2v2zm0-4h-2V7h2v6z",
|
||||
benefits: [
|
||||
"Up to 5 members on Plus, unlimited on Premium",
|
||||
"Centralized alert management",
|
||||
"Individual member dashboards",
|
||||
"Family-wide threat correlation",
|
||||
"Parental controls and child protection",
|
||||
],
|
||||
link: "/signup",
|
||||
linkText: "Start Family Plan",
|
||||
badge: "Family",
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureIcon(props: { d: string }) {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="0 0 20 20" fill="var(--color-brand-primary)" class="flex-shrink-0">
|
||||
<path d={props.d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeaturesPage() {
|
||||
return (
|
||||
<main>
|
||||
<Title>Kordant Features — Comprehensive Identity Protection</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<Badge variant="info" class="mb-4">6-in-1 Protection</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||
Complete Identity{" "}
|
||||
<span class="text-gradient-primary">Protection Suite</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
Six powerful tools working together to protect you, your family, and your digital life from modern threats.
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-20 md:py-28">
|
||||
<PageContainer>
|
||||
<div class="space-y-20 md:space-y-32">
|
||||
<For each={features}>
|
||||
{(feature, index) => {
|
||||
const isEven = () => index() % 2 === 0;
|
||||
return (
|
||||
<div class={
|
||||
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-center" +
|
||||
(isEven() ? "" : " lg:direction-rtl")
|
||||
}>
|
||||
<div class={isEven() ? "" : "lg:order-2"}>
|
||||
<Badge variant="default" class="mb-4">{feature.badge}</Badge>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] mb-6">
|
||||
{feature.description}
|
||||
</p>
|
||||
<ul class="space-y-3 mb-8">
|
||||
<For each={feature.benefits}>
|
||||
{(benefit) => (
|
||||
<li class="flex items-start gap-3 text-[var(--color-text-secondary)]">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="flex-shrink-0 mt-0.5">
|
||||
<circle cx="10" cy="10" r="10" fill="var(--color-brand-primary)" opacity="0.1"/>
|
||||
<path d="M7 10.5L8.5 12L13 8" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<A href={feature.link}>
|
||||
<span class="inline-flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)] hover:text-[var(--color-brand-accent)] transition-colors">
|
||||
{feature.linkText}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</A>
|
||||
</div>
|
||||
<div class={isEven() ? "lg:order-2" : ""}>
|
||||
<Card class="p-8 flex items-center justify-center min-h-[240px] bg-gradient-to-br from-[var(--color-brand-primary)]/5 to-[var(--color-brand-accent)]/5">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<FeatureIcon d={feature.iconD} />
|
||||
<span class="text-sm font-medium text-[var(--color-text-secondary)]">{feature.title}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-bg-secondary)]">
|
||||
<PageContainer>
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
All Tools, One Dashboard
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
Monitor all your security tools from a single, unified dashboard. Get real-time alerts, detailed analytics, and actionable insights.
|
||||
</p>
|
||||
<A href="/signup">
|
||||
<span class="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-[var(--color-brand-primary)] text-white font-medium hover:bg-[var(--color-brand-primary)]/90 transition-colors shadow-lg">
|
||||
Start Your Free Trial
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</A>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,498 @@
|
||||
import { For, Show, onMount } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import {
|
||||
ColorWaveBackground,
|
||||
HeroSection,
|
||||
HowItWorksSection,
|
||||
FeaturesGridSection,
|
||||
ForUsersSection,
|
||||
WhyKordantSection,
|
||||
CTABannerSection,
|
||||
} from "~/components/landing";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button, Badge, Card } from "~/components/ui";
|
||||
import { Typewriter } from "~/components/ui/Typewriter";
|
||||
import { ColorWaveBackground } from "~/components/landing/ColorWaveBackground";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
/* ── SVG Icon Helpers ── */
|
||||
|
||||
function IconPath(props: { d: string; class?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={cn("w-6 h-6 text-[var(--color-brand-primary)]", props.class)}
|
||||
>
|
||||
<path d={props.d} stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0">
|
||||
<path d="M4 9l3 3 7-7" stroke="var(--color-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Typed Data Arrays ── */
|
||||
|
||||
interface Step {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
iconD: string;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Enroll Your Identity",
|
||||
description:
|
||||
"Sign up and add your emails, phone numbers, and family members to create your protection profile.",
|
||||
iconD: "M12 4a4 4 0 100 8 4 4 0 000-8zM6 21v-2a4 4 0 014-4h4a4 4 0 014 4v2M17 8l2 2 4-4",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "We Monitor 24/7",
|
||||
description:
|
||||
"Our system runs continuous dark web scans, voiceprint detection, and spam filtering to catch threats early.",
|
||||
iconD: "M21 12a9 9 0 11-18 0 9 9 0 0118 0zM12 8v4l3 3M3 12h3m15 0h3M12 3v3m0 15v3",
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Get Instant Alerts",
|
||||
description:
|
||||
"Receive real-time notifications the moment a threat is detected, with clear guidance on what to do next.",
|
||||
iconD: "M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M12 22a2 2 0 01-2-2h4a2 2 0 01-2 2zM12 11v3m0-6v1",
|
||||
},
|
||||
];
|
||||
|
||||
interface Feature {
|
||||
title: string;
|
||||
description: string;
|
||||
iconD: string;
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
title: "DarkWatch",
|
||||
description:
|
||||
"Continuous dark web monitoring to detect your exposed credentials and personal data.",
|
||||
iconD: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 100 6 3 3 0 000-6z",
|
||||
},
|
||||
{
|
||||
title: "VoicePrint",
|
||||
description:
|
||||
"AI-powered voice clone detection to protect against deepfake voice scams.",
|
||||
iconD: "M12 4a4 4 0 00-4 4v8a4 4 0 008 0V8a4 4 0 00-4-4zM4 11v2m16-2v2M8 18.5A6 6 0 0016 18.5",
|
||||
},
|
||||
{
|
||||
title: "SpamShield",
|
||||
description:
|
||||
"Intelligent spam and scam call blocking that learns your patterns over time.",
|
||||
iconD: "M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4zM10 10l4 4m0-4l-4 4",
|
||||
},
|
||||
{
|
||||
title: "HomeTitle",
|
||||
description:
|
||||
"Property fraud alerts that notify you of unauthorized changes to your home records.",
|
||||
iconD: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
},
|
||||
{
|
||||
title: "RemoveBrokers",
|
||||
description:
|
||||
"Automatic data broker removal to minimize your personal data footprint online.",
|
||||
iconD: "M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2m-4 4v6m-4-6v6m-3-8v10a2 2 0 002 2h10a2 2 0 002-2V8H8z",
|
||||
},
|
||||
{
|
||||
title: "Family Plans",
|
||||
description:
|
||||
"Protect your whole household with shared monitoring, alerts, and management tools.",
|
||||
iconD: "M12 12a4 4 0 100-8 4 4 0 000 8zM5 22v-2a4 4 0 014-4h6a4 4 0 014 4v2M20 9a3 3 0 100-6 3 3 0 000 6zM16 22v-2a3 3 0 00-3-3h-2a3 3 0 00-3 3v2",
|
||||
},
|
||||
];
|
||||
|
||||
interface AudiencePanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
iconType: "individual" | "family";
|
||||
}
|
||||
|
||||
const audiencePanels: AudiencePanel[] = [
|
||||
{
|
||||
title: "For Individuals",
|
||||
description: "Personal identity protection tailored to your digital footprint.",
|
||||
iconType: "individual",
|
||||
items: [
|
||||
"Monitor personal email and phone numbers",
|
||||
"Dark web credential scanning",
|
||||
"Voiceprint clone detection",
|
||||
"Spam and scam call filtering",
|
||||
"Data broker opt-out service",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "For Families",
|
||||
description: "Group management tools to keep every household member safe.",
|
||||
iconType: "family",
|
||||
items: [
|
||||
"Add unlimited family members",
|
||||
"Shared alert dashboard",
|
||||
"Child account monitoring",
|
||||
"Family-wide dark web scans",
|
||||
"Centralized threat notifications",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface ValueProp {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
iconD: string;
|
||||
}
|
||||
|
||||
const valueProps: ValueProp[] = [
|
||||
{
|
||||
title: "Proactive, Not Reactive",
|
||||
description:
|
||||
"We detect threats before they cause damage, so you can act early.",
|
||||
iconD: "M13 3l-2 6h5l-3 8M4 14l5-5m0 0l5 5m-5-5v12",
|
||||
items: [
|
||||
"Real-time dark web scanning",
|
||||
"Pre-breach alerts and warnings",
|
||||
"Automated threat response",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI-Powered Detection",
|
||||
description:
|
||||
"Machine learning models trained on real scam data to catch the latest threats.",
|
||||
iconD: "M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3zM8 16l2 2-2 2M16 16l-2 2 2 2",
|
||||
items: [
|
||||
"Deepfake voice identification",
|
||||
"Pattern-based scam detection",
|
||||
"Continuous model improvement",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Privacy First",
|
||||
description:
|
||||
"Your data stays encrypted and private. We never sell your information.",
|
||||
iconD: "M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4zM9 12l2 2 4-4",
|
||||
items: [
|
||||
"End-to-end encrypted data",
|
||||
"GDPR and CCPA compliant",
|
||||
"Zero data selling policy",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Inline Icon Components ── */
|
||||
|
||||
function StepIcon(props: { d: string }) {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-white">
|
||||
<path d={props.d} stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AudienceIcon(props: { type: "individual" | "family" }) {
|
||||
if (props.type === "individual") {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-[var(--color-brand-primary)]">
|
||||
<circle cx="20" cy="14" r="6" fill="currentColor" />
|
||||
<path d="M8 32c0-6.6 5.4-12 12-12s12 5.4 12 12H8z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-[var(--color-brand-primary)]">
|
||||
<circle cx="14" cy="12" r="5" fill="currentColor" />
|
||||
<circle cx="26" cy="12" r="4" fill="currentColor" opacity="0.7" />
|
||||
<path d="M2 30c0-5 4-9 9-9 1.5 0 3 .4 4.2 1.1C16.5 21.5 18 21 20 21s3.5.5 4.8 1.1C26 21.4 27.5 21 29 21c5 0 9 4 9 9H2z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ── */
|
||||
|
||||
export default function Home() {
|
||||
let heroRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (heroRef) {
|
||||
heroRef.style.opacity = "1";
|
||||
heroRef.style.transform = "translateY(0)";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="overflow-hidden" style="--cut: clamp(16px, 2.5vw, 40px)">
|
||||
<Title>Kordant — AI-Powered Identity Protection</Title>
|
||||
|
||||
{/* Hero */}
|
||||
<ColorWaveBackground yOffset={-0.1} scale={0.65} speed={0.5} />
|
||||
<div class="relative z-10">
|
||||
<HeroSection />
|
||||
<section>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 md:w-16 md:h-16">
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z" fill="white" fill-opacity="0.9" />
|
||||
<path d="M20 24l3 2.5 5-5" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Get Started</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">Learn More</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<div
|
||||
class="bg-dot-grid relative z-10"
|
||||
style={{
|
||||
"clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)",
|
||||
}}
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<HowItWorksSection />
|
||||
<section id="how-it-works" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Three simple steps to full identity protection
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-12 md:gap-16">
|
||||
<For each={steps}>
|
||||
{(step, index) => {
|
||||
const isEven = index() % 2 === 0;
|
||||
return (
|
||||
<div class={cn("flex gap-8 md:flex-row flex-col", isEven ? "" : "md:flex-row-reverse")}>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-5">
|
||||
<div class="w-14 h-14 rounded-full gradient-primary shadow-glow-primary flex items-center justify-center shrink-0">
|
||||
<StepIcon d={step.iconD} />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-[var(--color-brand-primary)]">
|
||||
Step {step.number}
|
||||
</span>
|
||||
<h3 class="text-xl md:text-2xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p class="text-base text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 hidden md:block" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Platform Features */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 py-8 -my-10"
|
||||
style={{
|
||||
"clip-path":
|
||||
"polygon(0 0, 100% 0, 100% calc(100% - var(--cut)), 0 100%)",
|
||||
}}
|
||||
style={{ "clip-path": "polygon(0 0, 100% 0, 100% calc(100% - var(--cut)), 0 100%)" }}
|
||||
>
|
||||
<FeaturesGridSection />
|
||||
<section id="features" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Platform Features
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Comprehensive protection powered by AI and real-time monitoring
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={features}>
|
||||
{(feature) => (
|
||||
<Card class="hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 p-2 rounded-lg bg-[var(--color-bg-secondary)]">
|
||||
<IconPath d={feature.iconD} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* For Everyone */}
|
||||
<div
|
||||
class="bg-dot-grid"
|
||||
style={{
|
||||
"clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)",
|
||||
}}
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<ForUsersSection />
|
||||
<section id="for-users" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
For Everyone
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Whether you're protecting yourself or your whole family
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<For each={audiencePanels}>
|
||||
{(panel) => (
|
||||
<Card class="h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4">
|
||||
<AudienceIcon type={panel.iconType} />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{panel.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
{panel.description}
|
||||
</p>
|
||||
<ul class="space-y-3 flex-1">
|
||||
<For each={panel.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">{item}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Why Kordant + CTA */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 pt-8 -mt-10"
|
||||
style={{
|
||||
"clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)",
|
||||
}}
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<WhyKordantSection />
|
||||
<CTABannerSection />
|
||||
<section id="why-kordant" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Why Kordant
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Built on cutting-edge technology with your privacy at the core
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<For each={valueProps}>
|
||||
{(prop) => (
|
||||
<Card class="backdrop-blur-2xl">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-3 p-2 rounded-lg bg-[var(--color-bg-secondary)] w-fit">
|
||||
<IconPath d={prop.iconD} />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{prop.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-4 leading-relaxed">
|
||||
{prop.description}
|
||||
</p>
|
||||
<ul class="space-y-2 flex-1">
|
||||
<For each={prop.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">{item}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section id="cta" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Create Account</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">Sign In</Button>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
297
web/src/routes/pricing.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A, useSearchParams } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Button, Card } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface Plan {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
popular: boolean;
|
||||
}
|
||||
|
||||
interface FAQ {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
const plans: Plan[] = [
|
||||
{
|
||||
name: "Basic",
|
||||
price: "$9",
|
||||
period: "/month",
|
||||
description: "Essential identity protection for individuals",
|
||||
features: ["Dark web monitoring", "Email breach alerts", "Basic scam call blocking", "Monthly reports"],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: "Plus",
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "Advanced protection for you and your family",
|
||||
features: ["Everything in Basic", "VoicePrint AI detection", "HomeTitle fraud alerts", "RemoveBrokers automation", "Family sharing (up to 5)"],
|
||||
cta: "Start Free Trial",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Premium",
|
||||
price: "$39",
|
||||
period: "/month",
|
||||
description: "Maximum security for the whole household",
|
||||
features: ["Everything in Plus", "Unlimited family members", "Priority support 24/7", "Real-time alert correlation", "Advanced analytics dashboard", "Data broker suppression"],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
q: "How does Kordant detect voice clones?",
|
||||
a: "VoicePrint analyzes over 200 acoustic features in real-time, including micro-tremors and breathing patterns that AI clones can't replicate accurately.",
|
||||
},
|
||||
{
|
||||
q: "Is my data encrypted?",
|
||||
a: "Yes. All data is encrypted at rest using AES-256 and in transit using TLS 1.3. We never share or sell your personal information.",
|
||||
},
|
||||
{
|
||||
q: "Can I protect my whole family?",
|
||||
a: "Absolutely. Plus and Premium plans include family sharing with centralized monitoring and alert management for all household members.",
|
||||
},
|
||||
{
|
||||
q: "How does dark web monitoring work?",
|
||||
a: "DarkWatch continuously scans dark web forums, marketplaces, and data dumps for your email addresses, phone numbers, and other personal data.",
|
||||
},
|
||||
{
|
||||
q: "What happens after my free trial?",
|
||||
a: "Your trial includes full access to your selected plan for 14 days. You can cancel anytime before the trial ends with no charge.",
|
||||
},
|
||||
{
|
||||
q: "Can I remove my data from brokers?",
|
||||
a: "Yes. RemoveBrokers automates opt-out requests to over 200 data broker sites and verifies removal on your behalf.",
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonFeatures = [
|
||||
{ feature: "Dark web monitoring", basic: true, plus: true, premium: true },
|
||||
{ feature: "Email breach alerts", basic: true, plus: true, premium: true },
|
||||
{ feature: "Basic scam call blocking", basic: true, plus: true, premium: true },
|
||||
{ feature: "Monthly reports", basic: true, plus: true, premium: true },
|
||||
{ feature: "VoicePrint AI detection", basic: false, plus: true, premium: true },
|
||||
{ feature: "HomeTitle fraud alerts", basic: false, plus: true, premium: true },
|
||||
{ feature: "RemoveBrokers automation", basic: false, plus: true, premium: true },
|
||||
{ feature: "Family sharing", basic: false, plus: "Up to 5", premium: "Unlimited" },
|
||||
{ feature: "Priority support 24/7", basic: false, plus: false, premium: true },
|
||||
{ feature: "Real-time alert correlation", basic: false, plus: false, premium: true },
|
||||
{ feature: "Advanced analytics", basic: false, plus: false, premium: true },
|
||||
{ feature: "Data broker suppression", basic: false, plus: false, premium: true },
|
||||
];
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [openFaq, setOpenFaq] = createSignal<string | null>(null);
|
||||
|
||||
const signupUrl = () => `/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>Kordant Pricing — AI-Powered Identity Protection Plans</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<Badge variant="info" class="mb-4">Simple Pricing</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||
Protection That Fits{" "}
|
||||
<span class="text-gradient-primary">Your Budget</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel anytime.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />14-day free trial
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />Cancel anytime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-20 md:py-28">
|
||||
<PageContainer>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<Card
|
||||
class={cn(
|
||||
"relative flex flex-col",
|
||||
plan.popular && "ring-2 ring-[var(--color-brand-primary)] shadow-glow-primary",
|
||||
)}
|
||||
>
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge variant="info">Most Popular</Badge>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{plan.name}</h3>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">{plan.description}</p>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<span class="text-4xl font-bold text-[var(--color-text-primary)]">{plan.price}</span>
|
||||
<span class="text-sm text-[var(--color-text-tertiary)]">{plan.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 flex-1">
|
||||
<For each={plan.features}>
|
||||
{(feature) => (
|
||||
<li class="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<CheckIcon />
|
||||
{feature}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant={plan.popular ? "primary" : "secondary"} class="w-full">
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</A>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-bg-secondary)]">
|
||||
<PageContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Compare Plans
|
||||
</h2>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-[var(--color-border)]">
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Feature</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Basic</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-[var(--color-brand-primary)]">Plus</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Premium</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={comparisonFeatures}>
|
||||
{(row) => (
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<td class="px-4 py-3 text-sm text-[var(--color-text-primary)]">{row.feature}</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.basic === true ? <CheckIcon /> : row.basic === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.basic}</span>}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.plus === true ? <CheckIcon /> : row.plus === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.plus}</span>}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.premium === true ? <CheckIcon /> : row.premium === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.premium}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-20 md:py-28">
|
||||
<PageContainer>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={faqs}>
|
||||
{(faq) => {
|
||||
const isOpen = () => openFaq() === faq.q;
|
||||
return (
|
||||
<div class="border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
onClick={() => setOpenFaq(isOpen() ? null : faq.q)}
|
||||
>
|
||||
{faq.q}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class={cn("transition-transform duration-200", isOpen() && "rotate-180")}
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isOpen()}>
|
||||
<div class="px-5 pb-4 text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{faq.a}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-brand-primary)]">
|
||||
<PageContainer>
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-white/80 mb-8 max-w-2xl mx-auto">
|
||||
Join 50,000+ users who trust Kordant for AI-powered identity protection.
|
||||
</p>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant="primary" size="lg" class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { correlationRouter } from "./routers/correlation";
|
||||
import { reportsRouter } from "./routers/reports";
|
||||
import { schedulerRouter } from "./routers/scheduler";
|
||||
import { extensionRouter } from "./routers/extension";
|
||||
import { blogRouter } from "./routers/blog";
|
||||
import { adminRouter } from "./routers/admin";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -27,6 +29,8 @@ export const appRouter = createTRPCRouter({
|
||||
reports: reportsRouter,
|
||||
scheduler: schedulerRouter,
|
||||
extension: extensionRouter,
|
||||
blog: blogRouter,
|
||||
admin: adminRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
151
web/src/server/api/routers/admin.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { object, string, boolean, minLength, optional } from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, adminProcedure } from "~/server/api/utils";
|
||||
import { blogPosts } from "~/server/db/schema/marketing";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { eq, desc, count, sql } from "drizzle-orm";
|
||||
|
||||
const CreateBlogInput = wrap(
|
||||
object({
|
||||
title: string([minLength(1)]),
|
||||
slug: string([minLength(1)]),
|
||||
excerpt: optional(string()),
|
||||
content: string([minLength(1)]),
|
||||
authorName: optional(string()),
|
||||
coverImageUrl: optional(string()),
|
||||
tags: optional(string()),
|
||||
published: optional(boolean()),
|
||||
featured: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
const UpdateBlogInput = wrap(
|
||||
object({
|
||||
id: string(),
|
||||
title: optional(string([minLength(1)])),
|
||||
slug: optional(string([minLength(1)])),
|
||||
excerpt: optional(string()),
|
||||
content: optional(string([minLength(1)])),
|
||||
authorName: optional(string()),
|
||||
coverImageUrl: optional(string()),
|
||||
tags: optional(string()),
|
||||
published: optional(boolean()),
|
||||
featured: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
export const adminRouter = createTRPCRouter({
|
||||
// --- Dashboard ---
|
||||
stats: adminProcedure.query(async ({ ctx }) => {
|
||||
const [{ userCount }] = await ctx.db.select({ userCount: count() }).from(users);
|
||||
const [{ postCount }] = await ctx.db
|
||||
.select({ postCount: count() })
|
||||
.from(blogPosts)
|
||||
.where(eq(blogPosts.published, true));
|
||||
const [{ totalViews }] = await ctx.db
|
||||
.select({ totalViews: sql<number>`${count()}` })
|
||||
.from(blogPosts);
|
||||
const recentPosts = await ctx.db
|
||||
.select({ id: blogPosts.id, title: blogPosts.title, publishedAt: blogPosts.publishedAt })
|
||||
.from(blogPosts)
|
||||
.orderBy(desc(blogPosts.createdAt))
|
||||
.limit(5);
|
||||
return { userCount, postCount, totalViews, recentPosts };
|
||||
}),
|
||||
|
||||
// --- Blog ---
|
||||
blogList: adminProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.select().from(blogPosts).orderBy(desc(blogPosts.createdAt));
|
||||
}),
|
||||
|
||||
blogGet: adminProcedure
|
||||
.input(wrap(object({ id: string() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const post = await ctx.db
|
||||
.select().from(blogPosts)
|
||||
.where(eq(blogPosts.id, input.id)).limit(1);
|
||||
return post[0] ?? null;
|
||||
}),
|
||||
|
||||
blogCreate: adminProcedure
|
||||
.input(CreateBlogInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db
|
||||
.select({ id: blogPosts.id }).from(blogPosts)
|
||||
.where(eq(blogPosts.slug, input.slug)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Slug already exists" });
|
||||
}
|
||||
const tags = input.tags
|
||||
? input.tags.split(",").map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
const [newPost] = await ctx.db
|
||||
.insert(blogPosts)
|
||||
.values({
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
excerpt: input.excerpt,
|
||||
content: input.content,
|
||||
authorName: input.authorName,
|
||||
coverImageUrl: input.coverImageUrl,
|
||||
tags,
|
||||
published: input.published ?? false,
|
||||
featured: input.featured ?? false,
|
||||
publishedAt: input.published ? new Date() : undefined,
|
||||
}).returning();
|
||||
return newPost;
|
||||
}),
|
||||
|
||||
blogUpdate: adminProcedure
|
||||
.input(UpdateBlogInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updates } = input;
|
||||
const existing = await ctx.db
|
||||
.select().from(blogPosts)
|
||||
.where(eq(blogPosts.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
|
||||
}
|
||||
const set: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
if (key === "tags" && typeof value === "string") {
|
||||
set[key] = value.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
} else {
|
||||
set[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (set.published) {
|
||||
set.publishedAt = new Date();
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(blogPosts).set(set)
|
||||
.where(eq(blogPosts.id, id)).returning();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
blogDelete: adminProcedure
|
||||
.input(wrap(object({ id: string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(blogPosts).where(eq(blogPosts.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// --- Users ---
|
||||
userList: adminProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db
|
||||
.select({ id: users.id, email: users.email, name: users.name, role: users.role, createdAt: users.createdAt })
|
||||
.from(users).orderBy(desc(users.createdAt));
|
||||
}),
|
||||
|
||||
userUpdateRole: adminProcedure
|
||||
.input(wrap(object({ id: string(), role: string() })))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [updated] = await ctx.db
|
||||
.update(users).set({ role: input.role })
|
||||
.where(eq(users.id, input.id)).returning();
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
95
web/src/server/api/routers/blog.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { object, string, optional } from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
|
||||
import { blogPosts } from "~/server/db/schema/marketing";
|
||||
import { eq, and, desc, count, sql } from "drizzle-orm";
|
||||
|
||||
export const blogRouter = createTRPCRouter({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
wrap(
|
||||
object({
|
||||
tag: optional(string()),
|
||||
limit: optional(string()),
|
||||
offset: optional(string()),
|
||||
})
|
||||
)
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { tag, limit, offset } = input ?? {};
|
||||
const lim = limit ? parseInt(limit, 10) : 12;
|
||||
const off = offset ? parseInt(offset, 10) : 0;
|
||||
const conditions = [eq(blogPosts.published, true)];
|
||||
if (tag) {
|
||||
conditions.push(sql`${blogPosts.tags} LIKE ${`%${tag}%`}`);
|
||||
}
|
||||
const where = conditions.length > 1 ? and(...conditions) : conditions[0];
|
||||
|
||||
const posts = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(where)
|
||||
.orderBy(desc(blogPosts.publishedAt))
|
||||
.limit(lim)
|
||||
.offset(off);
|
||||
|
||||
const [{ total }] = await ctx.db
|
||||
.select({ total: count() })
|
||||
.from(blogPosts)
|
||||
.where(where);
|
||||
|
||||
return { posts, total };
|
||||
}),
|
||||
|
||||
bySlug: publicProcedure
|
||||
.input(wrap(object({ slug: string() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const post = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(and(eq(blogPosts.slug, input.slug), eq(blogPosts.published, true)))
|
||||
.limit(1);
|
||||
|
||||
if (post.length === 0) return null;
|
||||
|
||||
await ctx.db
|
||||
.update(blogPosts)
|
||||
.set({ viewCount: sql`${blogPosts.viewCount} + 1` })
|
||||
.where(eq(blogPosts.id, post[0].id));
|
||||
|
||||
const currentTags = post[0].tags as string[];
|
||||
const related = await ctx.db
|
||||
.select()
|
||||
.from(blogPosts)
|
||||
.where(
|
||||
and(
|
||||
eq(blogPosts.published, true),
|
||||
sql`${blogPosts.id} != ${post[0].id}`,
|
||||
sql`${blogPosts.tags} LIKE ${`%${currentTags[0]}%`}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(blogPosts.publishedAt))
|
||||
.limit(2);
|
||||
|
||||
return { post: post[0], related };
|
||||
}),
|
||||
|
||||
tags: publicProcedure.query(async ({ ctx }) => {
|
||||
const posts = await ctx.db
|
||||
.select({ tags: blogPosts.tags })
|
||||
.from(blogPosts)
|
||||
.where(eq(blogPosts.published, true));
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const row of posts) {
|
||||
const tags = row.tags as string[];
|
||||
for (const tag of tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tagCounts.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}),
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export const blogPosts = sqliteTable("blog_posts", {
|
||||
coverImageUrl: text("cover_image_url"),
|
||||
tags: text("tags", { mode: "json" }).notNull().$defaultFn(() => []),
|
||||
published: integer("published", { mode: "boolean" }).default(false).notNull(),
|
||||
featured: integer("featured", { mode: "boolean" }).default(false).notNull(),
|
||||
publishedAt: integer("published_at", { mode: "timestamp_ms" }),
|
||||
viewCount: integer("view_count").default(0).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||
|
||||
@@ -199,37 +199,210 @@ export async function seed() {
|
||||
|
||||
await db.insert(blogPosts).values([
|
||||
{
|
||||
slug: "what-is-dark-web-monitoring",
|
||||
title: "What Is Dark Web Monitoring and Why You Need It",
|
||||
excerpt: "Learn how dark web monitoring protects your personal information from cybercriminals.",
|
||||
content: "The dark web is a hidden part of the internet where cybercriminals buy and sell stolen data. Kordant helps you monitor your digital footprint...",
|
||||
authorName: "Kordant Team",
|
||||
tags: ["dark-web", "monitoring", "security"],
|
||||
slug: "ai-scam-trends-2026",
|
||||
title: "AI Scam Trends to Watch in 2026",
|
||||
excerpt: "As AI technology advances, scammers are finding new ways to exploit it. Here are the top threats to watch and how to protect yourself.",
|
||||
content: `## The Rise of AI-Powered Scams
|
||||
|
||||
Artificial intelligence has become a double-edged sword. While it powers innovation across industries, it also arms bad actors with sophisticated tools for deception.
|
||||
|
||||
### Voice Cloning Scams
|
||||
|
||||
One of the most alarming trends is the use of AI voice cloning. Scammers need only a few seconds of audio—often scraped from social media videos—to create convincing voice replicas. These are used to impersonate family members in distress, requesting urgent money transfers.
|
||||
|
||||
### Deepfake Video Conferencing
|
||||
|
||||
In 2025, we saw the first wave of deepfake video calls used in corporate impersonation scams. Attackers use real-time face-swapping technology to pose as executives during Zoom calls, authorizing fraudulent wire transfers.
|
||||
|
||||
### Automated Phishing at Scale
|
||||
|
||||
AI-generated phishing emails are now nearly indistinguishable from legitimate correspondence. Language models craft personalized messages that reference real events, contacts, and context, dramatically increasing click-through rates.
|
||||
|
||||
## How to Protect Yourself
|
||||
|
||||
1. **Verify voice requests** — If someone calls asking for money or sensitive information, hang up and call them back on a trusted number.
|
||||
2. **Use a safe word** — Establish a family safe word that can be used to verify identity in suspicious situations.
|
||||
3. **Enable Kordant VoicePrint** — Our AI detects voice clones by analyzing acoustic fingerprints that deepfakes cannot replicate.
|
||||
4. **Stay skeptical of urgency** — Scammers create false urgency to bypass your critical thinking.
|
||||
|
||||
## The Kordant Advantage
|
||||
|
||||
Kordant's multi-layered protection uses machine learning models trained on millions of scam attempts to identify emerging threats before they reach you. Our DarkWatch service continuously scans the dark web for exposed credentials, while VoicePrint protects against audio deepfakes.`,
|
||||
authorName: "Sarah Chen",
|
||||
tags: ["AI Safety", "Scam Alerts", "Deepfakes"],
|
||||
published: true,
|
||||
publishedAt: pastDate(60),
|
||||
featured: true,
|
||||
publishedAt: pastDate(10),
|
||||
viewCount: 1250,
|
||||
},
|
||||
{
|
||||
slug: "protect-your-family-online",
|
||||
title: "5 Tips to Protect Your Family's Online Privacy",
|
||||
excerpt: "Simple steps to keep your family's personal information safe from data brokers.",
|
||||
content: "In today's digital age, protecting your family's privacy is more important than ever. Here are five actionable tips...",
|
||||
authorName: "Kordant Team",
|
||||
tags: ["family", "privacy", "tips"],
|
||||
slug: "dark-web-monitoring-guide",
|
||||
title: "The Complete Guide to Dark Web Monitoring",
|
||||
excerpt: "Learn how dark web monitoring works, what data gets exposed, and how Kordant keeps your information safe from cybercriminals.",
|
||||
content: `## What Is Dark Web Monitoring?
|
||||
|
||||
The dark web is a hidden part of the internet where cybercriminals trade stolen data. Dark web monitoring services scan these hidden marketplaces, forums, and chat channels for your personal information.
|
||||
|
||||
### What Data Gets Exposed
|
||||
|
||||
When data breaches occur, the following types of information are commonly stolen and sold:
|
||||
|
||||
- **Email addresses and passwords** — The most common credential type traded on dark web markets
|
||||
- **Social Security numbers** — Often used for identity theft and tax fraud
|
||||
- **Credit card details** — Sold in bulk with CVV codes and billing addresses
|
||||
- **Medical records** — Highly valuable on the black market for insurance fraud
|
||||
- **Phone numbers** — Used for SIM swapping and phishing attacks
|
||||
|
||||
### How DarkWatch Works
|
||||
|
||||
DarkWatch continuously monitors over 200 dark web sources including forums, marketplaces, paste sites, and data dumps. When your information is detected, you receive an instant alert with full details about the exposure and recommended remediation steps.
|
||||
|
||||
### What to Do When Your Data Is Found
|
||||
|
||||
1. **Change affected passwords immediately** — Use unique passwords for every account
|
||||
2. **Enable two-factor authentication** — Prefer hardware keys or authenticator apps over SMS
|
||||
3. **Monitor financial accounts** — Check for unauthorized transactions or new accounts
|
||||
4. **Consider a credit freeze** — Lock your credit files at all three bureaus
|
||||
5. **File an identity theft report** — Report to IdentityTheft.gov for documentation`,
|
||||
authorName: "Mike Reynolds",
|
||||
tags: ["Dark Web", "Privacy"],
|
||||
published: true,
|
||||
publishedAt: pastDate(30),
|
||||
publishedAt: pastDate(15),
|
||||
viewCount: 820,
|
||||
},
|
||||
{
|
||||
slug: "understanding-data-brokers",
|
||||
title: "Understanding Data Brokers: Who Has Your Information?",
|
||||
excerpt: "A comprehensive guide to data brokers and how to opt out of their databases.",
|
||||
content: "Data brokers collect and sell personal information. This guide explains how they operate and how you can remove your data...",
|
||||
authorName: "Kordant Team",
|
||||
tags: ["data-brokers", "privacy", "opt-out"],
|
||||
slug: "protecting-family-identity",
|
||||
title: "Protecting Your Family's Digital Identity",
|
||||
excerpt: "Your family's personal data is at risk. Discover the steps you can take to protect everyone in your household from identity theft.",
|
||||
content: `## Why Family Identity Protection Matters
|
||||
|
||||
Identity thieves don't discriminate. Children, elderly parents, and everyone in between are potential targets. In fact, child identity theft is particularly insidious because it often goes undetected for years.
|
||||
|
||||
### Common Family Identity Threats
|
||||
|
||||
- **Child identity theft** — Stolen SSNs used to open credit lines, often discovered when the child applies for college loans
|
||||
- **Elder financial exploitation** — Scammers target seniors with tech support scams, grandparent scams, and Medicare fraud
|
||||
- **Family account sharing** — Shared passwords across family streaming services can expose personal data
|
||||
|
||||
### Steps to Protect Your Family
|
||||
|
||||
1. **Monitor every family member's data** — Use a family plan to track all household members from one dashboard
|
||||
2. **Teach safe online habits** — Help children understand phishing, oversharing, and digital footprints
|
||||
3. **Secure elderly relatives** — Set up fraud alerts with banks and review Medicare statements regularly
|
||||
4. **Use unique passwords** — Never share passwords between family members' accounts
|
||||
5. **Freeze credit for minors** — Contact all three credit bureaus to freeze your child's credit file
|
||||
|
||||
### Family Plans Made Simple
|
||||
|
||||
Kordant's family plans let you monitor up to 5 members on Plus and unlimited members on Premium. Each member gets individual protection while you maintain centralized control over alerts and settings.`,
|
||||
authorName: "Emily Torres",
|
||||
tags: ["Identity Theft", "Privacy"],
|
||||
published: true,
|
||||
publishedAt: pastDate(7),
|
||||
viewCount: 340,
|
||||
publishedAt: pastDate(20),
|
||||
viewCount: 640,
|
||||
},
|
||||
{
|
||||
slug: "deepfake-voice-scams",
|
||||
title: "Deepfake Voice Scams: How They Work and How to Spot Them",
|
||||
excerpt: "AI-generated voice clones are being used to impersonate loved ones. Here's what to listen for and how Kordant's VoicePrint can help.",
|
||||
content: `## The Mechanics of Voice Cloning
|
||||
|
||||
Modern AI voice cloning requires surprisingly little source material. Just 30 seconds of audio—from a voicemail, social media video, or recorded phone call—is enough to generate a convincing voice clone.
|
||||
|
||||
### Real Cases
|
||||
|
||||
In 2024, a family in Arizona received a frantic call from what sounded like their daughter, claiming she had been kidnapped and demanding ransom. The voice was so convincing that the family almost transferred $50,000 before reaching the real daughter.
|
||||
|
||||
### How VoicePrint Detects Clones
|
||||
|
||||
VoicePrint analyzes over 200 acoustic features that are nearly impossible for AI to replicate perfectly, including micro-tremors, breathing patterns, and formant transitions unique to each person's vocal tract.
|
||||
|
||||
### Warning Signs of a Voice Clone
|
||||
|
||||
- **Unnatural pauses** — AI voices often pause at odd intervals or between syllables
|
||||
- **Monotone delivery** — Lack of emotional variation even in distressing situations
|
||||
- **Background silence** — No ambient noise, even when the caller claims to be somewhere specific
|
||||
- **Repetition issues** — AI may struggle to repeat phrases naturally
|
||||
|
||||
### What to Do If You Suspect a Voice Clone
|
||||
|
||||
1. Hang up immediately
|
||||
2. Call the person back on a known, trusted number
|
||||
3. Use your family safe word to verify identity
|
||||
4. Report the incident to authorities`,
|
||||
authorName: "Sarah Chen",
|
||||
tags: ["Deepfakes", "AI Safety", "Scam Alerts"],
|
||||
published: true,
|
||||
publishedAt: pastDate(25),
|
||||
viewCount: 980,
|
||||
},
|
||||
{
|
||||
slug: "what-is-data-broker",
|
||||
title: "What Is a Data Broker and How to Remove Your Information",
|
||||
excerpt: "Data brokers collect and sell your personal information. Find out how to opt out and reclaim your privacy with RemoveBrokers.",
|
||||
content: `## Understanding Data Brokers
|
||||
|
||||
Data brokers are companies that collect personal information from various sources—public records, purchasing history, social media activity, and website tracking—then compile and sell this data to third parties.
|
||||
|
||||
### Why It Matters
|
||||
|
||||
Your data broker profile can include your home address, phone number, email address, income level, purchasing habits, political affiliation, and even health-related interests. This information can be used for targeted scams, identity theft, or unwanted marketing.
|
||||
|
||||
### How RemoveBrokers Helps
|
||||
|
||||
Kordant's RemoveBrokers service automates the opt-out process for hundreds of data broker sites, sending removal requests on your behalf and verifying that your information has been deleted.
|
||||
|
||||
### Top Data Brokers to Opt Out Of
|
||||
|
||||
- **Whitepages** — One of the most well-known people search sites
|
||||
- **Spokeo** — Aggregates data from public records and social media
|
||||
- **BeenVerified** — Offers background checks and people search
|
||||
- **Intelius** — Provides detailed personal profiles
|
||||
- **Pipl** — Used by both consumers and law enforcement
|
||||
|
||||
### The Ongoing Battle
|
||||
|
||||
Data brokers re-list information constantly. RemoveBrokers continuously monitors for new listings and automatically submits opt-out requests, ensuring your data stays off these platforms.`,
|
||||
authorName: "Alex Kim",
|
||||
tags: ["Privacy", "Identity Theft"],
|
||||
published: true,
|
||||
publishedAt: pastDate(30),
|
||||
viewCount: 530,
|
||||
},
|
||||
{
|
||||
slug: "kordant-product-update-may-2026",
|
||||
title: "Kordant Product Update — May 2026",
|
||||
excerpt: "New features including improved VoicePrint detection, expanded dark web monitoring, and a redesigned dashboard experience.",
|
||||
content: `## What's New in Kordant
|
||||
|
||||
We're excited to announce our May 2026 product update, packed with new features and improvements based on your feedback.
|
||||
|
||||
### Enhanced VoicePrint Detection
|
||||
|
||||
Our voice clone detection model has been retrained on a dataset 3x larger than before, improving detection accuracy to 99.7% while reducing false positives by 40%.
|
||||
|
||||
### Redesigned Dashboard
|
||||
|
||||
The dashboard has been completely redesigned for faster access to critical information. Key metrics are now displayed at a glance, and each service has its own dedicated section with detailed analytics.
|
||||
|
||||
### Expanded Dark Web Coverage
|
||||
|
||||
We've added monitoring for 50 additional dark web forums and marketplaces, bringing our total coverage to over 200 sources.
|
||||
|
||||
### New Family Features
|
||||
|
||||
- **Individual member dashboards** — Each family member can now view their own protection status
|
||||
- **Shared threat alerts** — When one member's data is compromised, all admins are notified
|
||||
- **Child protection mode** — Enhanced monitoring for minors with parental controls
|
||||
|
||||
### Coming Soon
|
||||
|
||||
Stay tuned for our upcoming SpamShield 2.0 with real-time call interception and HomeTitle property fraud alerts.`,
|
||||
authorName: "Product Team",
|
||||
tags: ["Product News"],
|
||||
published: true,
|
||||
publishedAt: pastDate(35),
|
||||
viewCount: 410,
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Blog posts created");
|
||||
|
||||
11
web/src/server/jobs/start.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Scheduler entry point — runs BullMQ worker + cron scheduler on pan server
|
||||
import "dotenv/config";
|
||||
|
||||
import { initialize, shutdown } from "~/server/jobs";
|
||||
|
||||
await initialize();
|
||||
|
||||
process.on("SIGTERM", () => shutdown());
|
||||
process.on("SIGINT", () => shutdown());
|
||||
|
||||
console.log("[scheduler] Running on pan — connected to Turso + Redis");
|
||||
80
web/src/theme/tokens.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY
|
||||
// Run: node scripts/generate-tokens.mjs
|
||||
|
||||
export const tokenColors = {
|
||||
brand: {
|
||||
primary: "#4F46E5",
|
||||
primary_light: "#818CF8",
|
||||
primary_dark: "#4338CA",
|
||||
accent: "#06B6D4",
|
||||
accent_light: "#67E8F9",
|
||||
accent_dark: "#0891B2",
|
||||
},
|
||||
semantic: {
|
||||
success_bg: { light: "#ECFEFF", dark: "#0C4A6E" },
|
||||
warning_bg: { light: "#FFFBEB", dark: "#78350F" },
|
||||
error_bg: { light: "#FEF2F2", dark: "#7F1D1D" },
|
||||
info_bg: { light: "#EEF2FF", dark: "#1E1B4B" },
|
||||
},
|
||||
background: {
|
||||
bg: { light: "#FAFBFC", dark: "#111827" },
|
||||
bg_secondary: { light: "#F3F4F6", dark: "#1F2937" },
|
||||
bg_tertiary: { light: "#E5E7EB", dark: "#374151" },
|
||||
},
|
||||
text: {
|
||||
text_primary: { light: "#111827", dark: "#F9FAFB" },
|
||||
text_secondary: { light: "#6B7280", dark: "#D1D5DB" },
|
||||
text_tertiary: { light: "#9CA3AF", dark: "#9CA3AF" },
|
||||
},
|
||||
border: {
|
||||
border: { light: "#E5E7EB", dark: "#374151" },
|
||||
border_dark: { light: "#D1D5DB", dark: "#4B5563" },
|
||||
},
|
||||
};
|
||||
|
||||
export const tokenTypography = {
|
||||
fontFamily: "Inter",
|
||||
fallback: "-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
|
||||
scale: {
|
||||
caption: { size: "12px", lineHeight: "16px" },
|
||||
body: { size: "16px", lineHeight: "24px" },
|
||||
body_large: { size: "18px", lineHeight: "28px" },
|
||||
headline: { size: "20px", lineHeight: "28px" },
|
||||
title: { size: "24px", lineHeight: "32px" },
|
||||
large_title: { size: "32px", lineHeight: "40px" },
|
||||
display: { size: "48px", lineHeight: "56px" },
|
||||
},
|
||||
weights: {
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
},
|
||||
};
|
||||
|
||||
export const tokenSpacing = {
|
||||
0: "0px",
|
||||
xs: "4px",
|
||||
sm: "8px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
xl: "32px",
|
||||
xxl: "48px",
|
||||
xxxl: "64px",
|
||||
};
|
||||
|
||||
export const tokenShadows = {
|
||||
sm: "0px 1px 2px 0px rgba(0, 0, 0, 0.05)",
|
||||
md: "0px 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||
lg: "0px 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
||||
xl: "0px 20px 25px -5px rgba(0, 0, 0, 0.15)",
|
||||
};
|
||||
|
||||
export const tokenRadius = {
|
||||
none: "0px",
|
||||
sm: "4px",
|
||||
md: "8px",
|
||||
lg: "12px",
|
||||
xl: "16px",
|
||||
full: "9999px",
|
||||
};
|
||||