Compare commits
43 Commits
dump-branc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0851a08d8f | ||
|
|
2b5851420d | ||
|
|
103101c5d9 | ||
|
|
da7c6e0cf6 | ||
|
|
0e4e41d672 | ||
|
|
4dfd954c9e | ||
|
|
39c820cf21 | ||
|
|
b7b8f6873f | ||
|
|
d1f06d0361 | ||
|
|
cabf1dc2c0 | ||
|
|
7ccdd54cd2 | ||
|
|
65be7701e3 | ||
|
|
b3f0d50f01 | ||
|
|
a2d6859c1a | ||
|
|
6ff46125ab | ||
|
|
f09262a597 | ||
|
|
47fed0b880 | ||
|
|
71c19e0a51 | ||
|
|
562e66d3ce | ||
|
|
3c9ee2940f | ||
|
|
f6ebcdc656 | ||
|
|
5a942d9ca1 | ||
|
|
991906422d | ||
|
|
15b3bd9465 | ||
|
|
8839201185 | ||
|
|
701fc25630 | ||
|
|
81ba5d85e9 | ||
|
|
e5ec5c9ee1 | ||
|
|
7f57f8292b | ||
|
|
27c6300509 | ||
|
|
5fa823096e | ||
|
|
1dd8493689 | ||
|
|
fa0d501063 | ||
|
|
af24186d63 | ||
|
|
fce7a0ec72 | ||
|
|
8a9703c24a | ||
|
|
bf701f8342 | ||
|
|
0ff41822af | ||
|
|
86f9cf803a | ||
|
|
02b85eefc7 | ||
|
|
c3f3c07075 | ||
|
|
50f08f8177 | ||
|
|
a545cbfcca |
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
||||
# Nuxt build artifacts
|
||||
.output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Node modules
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
31
.env.example
31
.env.example
@@ -1,14 +1,19 @@
|
||||
# Auth0 configuration
|
||||
AUTH0_DOMAIN=
|
||||
AUTH0_CLIENT_ID=
|
||||
AUTH0_CLIENT_SECRET=
|
||||
AUTH0_AUDIENCE=
|
||||
AUTH0_SCOPE=openid profile email
|
||||
AUTH0_BASE_URL=http://localhost:3000
|
||||
AUTH0_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
# Firebase Configuration
|
||||
# Get these values from your Firebase Console -> Project Settings -> General -> Your apps
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key_here
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id_here
|
||||
# Use the bucket ID (e.g. project-id.appspot.com), not the web URL.
|
||||
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket_here
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id_here
|
||||
NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=your_firebase_measurement_id_here
|
||||
|
||||
# Supabase configuration
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_STORAGE_BUCKET=designs
|
||||
# Stripe Configuration
|
||||
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# Backend Configuration
|
||||
NUXT_PUBLIC_BACKEND_URL=http://localhost:3000
|
||||
NUXT_PUBLIC_STORAGE_URL=http://localhost:9000
|
||||
140
.gitea/workflows/deploy-dev.yml
Normal file
140
.gitea/workflows/deploy-dev.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
name: Deploy Development
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
# 1️⃣ Checkout code
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
git clone $GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git /workspace/repo
|
||||
cd /workspace/repo
|
||||
git checkout $GITHUB_REF_NAME
|
||||
|
||||
# 2️⃣ Build image
|
||||
- name: Build Docker image
|
||||
shell: sh
|
||||
run: |
|
||||
cd /workspace/repo
|
||||
docker build -t slipmatz-web:dev .
|
||||
docker save slipmatz-web:dev | gzip > slipmatz-web_dev.tar.gz
|
||||
|
||||
# 3️⃣ Setup SSH
|
||||
- name: Setup SSH
|
||||
shell: sh
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
# 4️⃣ Upload artifacts
|
||||
- name: Upload image and compose
|
||||
shell: sh
|
||||
env:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
scp -i ~/.ssh/id_ed25519 \
|
||||
/workspace/repo/slipmatz-web_dev.tar.gz \
|
||||
/workspace/repo/docker-compose.yml \
|
||||
${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/
|
||||
|
||||
# 5️⃣ Deploy on server
|
||||
- name: Deploy on server
|
||||
shell: sh
|
||||
env:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 $DEPLOY_USER@$DEPLOY_HOST << 'EOF'
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="/var/www/apps/slipmatz-web_dev"
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
|
||||
echo "📦 Loading image"
|
||||
docker load < /tmp/slipmatz-web_dev.tar.gz
|
||||
|
||||
echo "🧹 Removing old slipmatz-web images"
|
||||
docker images | grep slipmatz-web | grep -v "$(docker images slipmatz-web:dev -q)" | awk '{print $3}' | xargs -r docker rmi -f || true
|
||||
|
||||
echo "📄 Updating compose file"
|
||||
cp /tmp/docker-compose.yml "$DEPLOY_DIR/"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "🔍 Checking .env file"
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ .env file not found at $DEPLOY_DIR/.env"
|
||||
echo "Please create it first with required variables:"
|
||||
echo " - NUXT_PUBLIC_*, STRIPE_SECRET_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔧 Fixing .env permissions"
|
||||
sudo chown $USER:$USER .env
|
||||
sudo chmod 600 .env
|
||||
|
||||
echo "🌐 Ensure networks"
|
||||
docker network inspect traefik-public >/dev/null 2>&1 || \
|
||||
docker network create traefik-public
|
||||
docker network inspect crew-app-net >/dev/null 2>&1 || \
|
||||
docker network create crew-app-net
|
||||
|
||||
echo "🚀 Starting containers (env vars from .env file)"
|
||||
export COMPOSE_PROJECT_NAME=slipmatz-web-dev
|
||||
docker compose down || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "🧪 Testing container health"
|
||||
sleep 15
|
||||
if ! docker ps | grep -q slipmatz-web; then
|
||||
echo "❌ Container not running"
|
||||
docker compose logs --tail=50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🎉 Dev deployment complete!"
|
||||
docker compose ps
|
||||
|
||||
echo "🧹 Cleanup"
|
||||
rm -f /tmp/slipmatz-web_dev.tar.gz /tmp/docker-compose.yml
|
||||
|
||||
echo "🗑️ Aggressive Docker cleanup to reclaim space"
|
||||
docker image prune -af --filter "until=24h" || true
|
||||
docker container prune -f || true
|
||||
docker volume prune -f || true
|
||||
docker builder prune -af --filter "until=48h" || true
|
||||
|
||||
echo "💾 Docker space usage:"
|
||||
docker system df
|
||||
|
||||
echo "✅ Dev deployment completed successfully"
|
||||
EOF
|
||||
|
||||
# 6️⃣ Verify deployment
|
||||
- name: Verify deployment
|
||||
shell: sh
|
||||
env:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
sleep 5
|
||||
ssh -i ~/.ssh/id_ed25519 $DEPLOY_USER@$DEPLOY_HOST \
|
||||
"docker ps | grep slipmatz-web || exit 1"
|
||||
93
.gitea/workflows/deploy.yml
Normal file
93
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
git clone $GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git /workspace/repo || true
|
||||
cd /workspace/repo
|
||||
git fetch origin $GITHUB_REF_NAME
|
||||
git checkout $GITHUB_REF_NAME
|
||||
git pull origin $GITHUB_REF_NAME
|
||||
|
||||
- name: Build Docker Image
|
||||
shell: sh
|
||||
run: |
|
||||
cd /workspace/repo
|
||||
docker build -t slipmatz-web:latest .
|
||||
docker save slipmatz-web:latest | gzip > slipmatz-web.tar.gz
|
||||
|
||||
- name: Setup SSH and Deploy
|
||||
shell: sh
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keygen -y -f ~/.ssh/deploy_key > /dev/null 2>&1 || { echo "Error: Invalid SSH key format"; exit 1; }
|
||||
|
||||
cd /workspace/repo
|
||||
scp -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key slipmatz-web.tar.gz docker-compose.yml "$DEPLOY_USER@$DEPLOY_HOST:/tmp/"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
DEPLOY_DIR="/var/www/apps/slipmatz-web"
|
||||
mkdir -p $DEPLOY_DIR
|
||||
cd /tmp
|
||||
docker load < slipmatz-web.tar.gz
|
||||
|
||||
echo "Removing old slipmatz-web images"
|
||||
CURRENT_IMAGE=$(docker images slipmatz-web:latest -q)
|
||||
docker images | grep slipmatz-web | grep -v "$CURRENT_IMAGE" | awk "{print \$3}" | xargs -r docker rmi -f || true
|
||||
|
||||
cp docker-compose.yml $DEPLOY_DIR/
|
||||
cd $DEPLOY_DIR
|
||||
|
||||
echo "Checking .env file"
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found at $DEPLOY_DIR/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose down || true
|
||||
docker image prune -f
|
||||
docker network inspect traefik-public >/dev/null 2>&1 || docker network create traefik-public
|
||||
docker network inspect crew-app-net >/dev/null 2>&1 || docker network create crew-app-net
|
||||
export DOMAIN=slipmatz.com
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
rm -f /tmp/slipmatz-web.tar.gz /tmp/docker-compose.yml
|
||||
|
||||
echo "Aggressive Docker cleanup to reclaim space"
|
||||
docker image prune -af --filter "until=24h" || true
|
||||
docker container prune -f || true
|
||||
docker volume prune -f || true
|
||||
docker builder prune -af --filter "until=48h" || true
|
||||
echo "Docker space usage:"
|
||||
docker system df
|
||||
|
||||
echo "Production deployment completed successfully!"
|
||||
echo "Application available at: https://slipmatz.com"
|
||||
'
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
|
||||
- name: Health Check
|
||||
shell: sh
|
||||
run: |
|
||||
sleep 10
|
||||
curl -f https://slipmatz.com || exit 1
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm install --production --legacy-peer-deps
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
65
GHCR-AUTH.md
Normal file
65
GHCR-AUTH.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Login to GitHub Container Registry (GHCR)
|
||||
|
||||
## On the Server
|
||||
|
||||
### 1. Create GitHub Personal Access Token
|
||||
Go to: https://github.com/settings/tokens
|
||||
|
||||
Click "Generate new token (classic)"
|
||||
|
||||
Select scopes:
|
||||
- ✅ `read:packages` (required)
|
||||
- ✅ `write:packages` (if you need to push)
|
||||
|
||||
Copy the token (starts with `ghp_`)
|
||||
|
||||
### 2. Login to GHCR
|
||||
|
||||
```bash
|
||||
# On your server
|
||||
echo YOUR_GITHUB_TOKEN | docker login ghcr.io -u franknstayn --password-stdin
|
||||
```
|
||||
|
||||
Or export as environment variable:
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN=ghp_your_token_here
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u franknstayn --password-stdin
|
||||
```
|
||||
|
||||
### 3. Pull the image
|
||||
|
||||
```bash
|
||||
cd crew-infrastructure
|
||||
docker compose pull slipmatz_web
|
||||
docker compose up -d slipmatz_web
|
||||
```
|
||||
|
||||
## Make Package Public (Alternative - No Auth Required)
|
||||
|
||||
If you want to avoid authentication:
|
||||
|
||||
1. Go to: https://github.com/franknstayn?tab=packages
|
||||
2. Click on `slipmatz-web` package
|
||||
3. Package settings (right sidebar)
|
||||
4. "Change visibility" → Select "Public"
|
||||
5. Confirm
|
||||
|
||||
Then you can pull without login:
|
||||
```bash
|
||||
docker compose pull slipmatz_web
|
||||
```
|
||||
|
||||
## Recommended for Production
|
||||
|
||||
Store credentials securely:
|
||||
|
||||
```bash
|
||||
# On server, add to ~/.bashrc or ~/.zshrc
|
||||
export GITHUB_TOKEN=ghp_your_token_here
|
||||
|
||||
# Add to docker-compose.yml env
|
||||
echo "GITHUB_TOKEN=$GITHUB_TOKEN" >> .env
|
||||
```
|
||||
|
||||
Or use docker credential helper for permanent storage.
|
||||
133
GHCR-SETUP.md
Normal file
133
GHCR-SETUP.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# GitHub Container Registry Setup for Slipmatz Web
|
||||
|
||||
## Overview
|
||||
The slipmatz-web application is automatically built and published to GitHub Container Registry (GHCR) on every push to main/develop branches.
|
||||
|
||||
## Container Registry URL
|
||||
```
|
||||
ghcr.io/franknstayn/slipmatz-web:latest
|
||||
```
|
||||
|
||||
## GitHub Actions Workflow
|
||||
|
||||
The workflow (`.github/workflows/docker-publish.yml`) automatically:
|
||||
1. Builds the Docker image on every push to `main` or `develop`
|
||||
2. Pushes to GitHub Container Registry
|
||||
3. Tags images with:
|
||||
- `latest` (for main branch)
|
||||
- Branch name (e.g., `develop`)
|
||||
- Git SHA (e.g., `main-abc1234`)
|
||||
- Version tags (for tagged releases)
|
||||
|
||||
## Repository Setup
|
||||
|
||||
### 1. Enable GitHub Actions
|
||||
- Go to repository Settings → Actions → General
|
||||
- Ensure "Read and write permissions" is enabled for GITHUB_TOKEN
|
||||
|
||||
### 2. Make Package Public (Optional)
|
||||
- Go to repository → Packages → slipmatz-web
|
||||
- Package settings → Change visibility → Public
|
||||
- (Or keep private if preferred)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Pull from Registry (Recommended for Production)
|
||||
```bash
|
||||
cd crew-infrastructure
|
||||
docker compose pull slipmatz_web
|
||||
docker compose up -d slipmatz_web
|
||||
```
|
||||
|
||||
### Build Locally (Development)
|
||||
Uncomment the `build` section in docker-compose.yml:
|
||||
```yaml
|
||||
slipmatz_web:
|
||||
build:
|
||||
context: ../apps/slipmatz-web
|
||||
dockerfile: Dockerfile
|
||||
# image: ghcr.io/franknstayn/slipmatz-web:latest
|
||||
```
|
||||
|
||||
## Using Specific Versions
|
||||
|
||||
### Pull specific tag
|
||||
```bash
|
||||
docker pull ghcr.io/franknstayn/slipmatz-web:develop
|
||||
docker pull ghcr.io/franknstayn/slipmatz-web:v1.0.0
|
||||
docker pull ghcr.io/franknstayn/slipmatz-web:main-abc1234
|
||||
```
|
||||
|
||||
### Use in docker-compose.yml
|
||||
```yaml
|
||||
slipmatz_web:
|
||||
image: ghcr.io/franknstayn/slipmatz-web:v1.0.0
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Automatic Builds Trigger On:
|
||||
- Push to `main` → builds `latest` tag
|
||||
- Push to `develop` → builds `develop` tag
|
||||
- Create tag `v*` → builds version tags
|
||||
- Pull request → builds but doesn't push
|
||||
|
||||
### Workflow Features:
|
||||
- ✅ Docker layer caching (faster builds)
|
||||
- ✅ Multi-platform support ready
|
||||
- ✅ Automatic tagging strategy
|
||||
- ✅ Build cache optimization
|
||||
|
||||
## Manual Build and Push
|
||||
|
||||
If you need to manually build and push:
|
||||
|
||||
```bash
|
||||
# Login to GHCR
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
|
||||
|
||||
# Build and tag
|
||||
cd apps/slipmatz-web
|
||||
docker build -t ghcr.io/franknstayn/slipmatz-web:latest .
|
||||
|
||||
# Push
|
||||
docker push ghcr.io/franknstayn/slipmatz-web:latest
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Error
|
||||
If you get permission denied when pulling:
|
||||
```bash
|
||||
# Login with GitHub Personal Access Token
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u franknstayn --password-stdin
|
||||
```
|
||||
|
||||
Create token at: https://github.com/settings/tokens
|
||||
- Scope needed: `read:packages`
|
||||
|
||||
### Image Not Found
|
||||
- Check if workflow ran successfully in Actions tab
|
||||
- Verify package exists at: https://github.com/franknstayn?tab=packages
|
||||
- Ensure package visibility matches your needs (public/private)
|
||||
|
||||
### Old Image Cached
|
||||
```bash
|
||||
docker compose pull slipmatz_web --no-cache
|
||||
docker compose up -d slipmatz_web
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Faster Deployments**: Pull pre-built images instead of building on server
|
||||
✅ **Version Control**: Track and rollback to specific image versions
|
||||
✅ **Consistent Builds**: Same image across environments
|
||||
✅ **CI/CD Ready**: Automatic builds on code push
|
||||
✅ **Free**: GitHub Packages is free for public repos
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Push code to trigger first build
|
||||
2. Verify image appears in GitHub Packages
|
||||
3. Pull and deploy on production server
|
||||
4. Set up similar workflow for slipmatz-backend
|
||||
48
README.md
48
README.md
@@ -7,55 +7,17 @@ Nuxt 4 single-page experience for creating custom slipmat artwork. Users pick a
|
||||
- Template presets for popular vinyl diameters with bleed & safe-zone guides
|
||||
- Fabric.js-powered editor: drag, scale, rotate, and stack elements inside a clipped circular canvas
|
||||
- Tools for adding text, circles, rectangles, and uploading artwork
|
||||
- Live preview card with instant web-resolution snapshot and turntable mockup
|
||||
- Live preview card with instant web-resolution snapshot
|
||||
- One-click export that produces both preview and print PNGs and offers direct downloads
|
||||
- Auth0-protected workspace with Supabase-backed “Save to Library” persistence
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set Auth0 + Supabase credentials
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit [http://localhost:3000](http://localhost:3000) to start designing. Authentication is required for every route except the Auth0 callback.
|
||||
|
||||
### Auth0 & Supabase configuration
|
||||
|
||||
1. **Auth0 application**
|
||||
- Create a SPA application and note the domain, client ID, and client secret.
|
||||
- Add `http://localhost:3000` to the allowed callback, logout, and web origins.
|
||||
- If you plan to call custom APIs, configure an audience and scope (defaults to `openid profile email`).
|
||||
|
||||
2. **Supabase project**
|
||||
- Create a storage bucket named `designs` (or change `SUPABASE_STORAGE_BUCKET`).
|
||||
- Create a Postgres table for persisted projects:
|
||||
|
||||
```sql
|
||||
create table public.designs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id text not null,
|
||||
name text not null,
|
||||
template_id text not null,
|
||||
preview_path text not null,
|
||||
preview_url text,
|
||||
design_json jsonb not null,
|
||||
notes text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index designs_user_id_updated_at_idx
|
||||
on public.designs (user_id, updated_at desc);
|
||||
```
|
||||
|
||||
- Grant read/write access to the AUTH0 users via Row Level Security (example policy: `user_id = auth.jwt()->>'sub'`).
|
||||
- Generate an anon key and service role key; place them in `.env` along with the project URL.
|
||||
|
||||
3. **Environment**
|
||||
- Populate `.env` using `.env.example` as a guide.
|
||||
- Restart the dev server after changes.
|
||||
Visit [http://localhost:3000](http://localhost:3000) to start designing.
|
||||
|
||||
## 🛠️ Production Builds
|
||||
|
||||
@@ -75,19 +37,17 @@ Nuxt outputs the server bundle into `.output/`. You can serve it with `node .out
|
||||
- _Web preview_: 1024×1024 PNG suitable for the storefront and checkout review.
|
||||
- _Print-ready PNG_: Exact template resolution (e.g. 3600×3600 for a 12" slipmat at 300 DPI) for production.
|
||||
|
||||
Saving a project now serializes the Fabric.js canvas, uploads the preview PNG to Supabase Storage, and stores metadata within the `designs` table for quick retrieval.
|
||||
High-res exports remain client-side (no upload yet). Wire the `exportDesign` / `productionBlob` values into your backend to submit orders.
|
||||
|
||||
## 📦 Tech Stack
|
||||
|
||||
- [Nuxt 4](https://nuxt.com/) + Vite
|
||||
- [Fabric.js 6](http://fabricjs.com/) for canvas editing
|
||||
- Tailwind CSS (via `@tailwindcss/vite`) for styling utilities
|
||||
- [Auth0 Vue SDK](https://auth0.com/docs/libraries/auth0-vue) for authentication
|
||||
- [Supabase](https://supabase.com/) (Postgres + Storage) for persistence
|
||||
|
||||
## 🧭 Next Ideas
|
||||
|
||||
- Project version history and “duplicate design” shortcuts
|
||||
- Persist projects (auth + cloud storage)
|
||||
- CMYK color profile previews & bleed handling
|
||||
- 3D platter preview using Three.js
|
||||
- Admin dashboard for incoming print jobs
|
||||
|
||||
157
app/components/AppNavbar.vue
Normal file
157
app/components/AppNavbar.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
|
||||
const loginModal = useLoginModal()
|
||||
const route = useRoute()
|
||||
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const showMenu = ref(false)
|
||||
|
||||
const displayName = computed(() => backendUser.value?.name || user.value?.displayName || backendUser.value?.email || user.value?.email || "Account")
|
||||
|
||||
const avatarInitials = computed(() => {
|
||||
const source = displayName.value
|
||||
if (!source) {
|
||||
return "S"
|
||||
}
|
||||
const parts = source.trim().split(/\s+/)
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||
})
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModal.value = true
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
showMenu.value = false
|
||||
} catch (error) {
|
||||
console.error('Sign out failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
if (showMenu.value) {
|
||||
showMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownRef.value) {
|
||||
return
|
||||
}
|
||||
if (!dropdownRef.value.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initAuth()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMenu()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-30 border-b border-slate-800/60 bg-slate-950/80 backdrop-blur">
|
||||
<div class="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<NuxtLink to="/" class="text-lg font-semibold text-white transition hover:text-sky-300 sm:text-xl">
|
||||
Slipmatz
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Show user info and logout when authenticated -->
|
||||
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
|
||||
<span class="hidden text-sm text-slate-300 sm:inline">{{ backendUser?.email || user.email }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-sm font-semibold uppercase text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||
@click.stop="toggleMenu"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
>
|
||||
{{ avatarInitials }}
|
||||
</button>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showMenu"
|
||||
class="absolute right-0 top-12 w-56 rounded-2xl border border-slate-800/70 bg-slate-900/95 p-3 shadow-xl shadow-slate-950/50 backdrop-blur"
|
||||
role="menu"
|
||||
>
|
||||
<p class="px-3 text-xs uppercase tracking-[0.25em] text-slate-500">Signed in as</p>
|
||||
<p class="px-3 text-sm font-medium text-slate-200">{{ displayName }}</p>
|
||||
<p class="px-3 text-xs text-slate-500">{{ backendUser?.email || user.email }}</p>
|
||||
<div class="my-3 h-px bg-slate-800"></div>
|
||||
<NuxtLink
|
||||
to="/profile"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
|
||||
role="menuitem"
|
||||
>
|
||||
Profile
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
|
||||
role="menuitem"
|
||||
>
|
||||
Orders
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/10"
|
||||
@click="handleSignOut"
|
||||
role="menuitem"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Show login button when not authenticated -->
|
||||
<div v-else-if="!isLoading" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="openLoginModal"
|
||||
class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="h-8 w-16 animate-pulse rounded bg-slate-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginModal />
|
||||
</nav>
|
||||
</template>
|
||||
122
app/components/LoginModal.vue
Normal file
122
app/components/LoginModal.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
const { signInWithEmail, signInWithGoogle, error } = useAuth();
|
||||
|
||||
const isOpen = useLoginModal();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const loginError = ref<string | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const handleEmailLogin = async () => {
|
||||
try {
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithEmail(email.value, password.value);
|
||||
isOpen.value = false;
|
||||
// Reset form
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
loginError.value = "Login failed. Please check your credentials.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
isOpen.value = false;
|
||||
} catch (err) {
|
||||
loginError.value = "Google login failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed left-0 top-0 z-50 flex min-h-screen w-full items-center justify-center bg-black/60 px-4"
|
||||
@click.self="isOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-6 text-2xl font-bold text-white">Sign In</h2>
|
||||
|
||||
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{{ isSubmitting ? "Signing in..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="my-4 flex items-center">
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
<span class="px-3 text-sm text-slate-400">or</span>
|
||||
<div class="flex-1 border-t border-slate-700"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleGoogleLogin"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 px-4 py-2 text-white hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div v-if="loginError || error" class="mt-4 text-sm text-red-400">
|
||||
{{ loginError || error }}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-400">
|
||||
Need an account?
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="text-sky-400 hover:text-sky-300"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Create one instead
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="isOpen = false"
|
||||
class="mt-4 text-sm text-slate-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,154 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
previewUrl: string | null;
|
||||
templateLabel: string;
|
||||
productionPixels: number;
|
||||
isExporting: boolean;
|
||||
projectName: string;
|
||||
isSaving: boolean;
|
||||
canSave: boolean;
|
||||
isCheckoutPending: boolean;
|
||||
checkoutPrice: number;
|
||||
checkoutError: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "download-preview"): void;
|
||||
(e: "download-production"): void;
|
||||
(e: "export"): void;
|
||||
(e: "update:projectName", value: string): void;
|
||||
(e: "save"): void;
|
||||
(e: "checkout"): void;
|
||||
}>();
|
||||
|
||||
const viewMode = ref<"flat" | "turntable">("flat");
|
||||
const handleCheckout = () => emit("checkout");
|
||||
|
||||
const isFlat = computed(() => viewMode.value === "flat");
|
||||
|
||||
const handleSelectView = (mode: "flat" | "turntable") => {
|
||||
viewMode.value = mode;
|
||||
};
|
||||
|
||||
const handleExport = () => emit("export");
|
||||
const handleDownloadPreview = () => emit("download-preview");
|
||||
const handleDownloadProduction = () => emit("download-production");
|
||||
const handleProjectNameInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit("update:projectName", target.value);
|
||||
};
|
||||
const handleSave = () => emit("save");
|
||||
const isSaveDisabled = computed(
|
||||
() => !props.previewUrl || props.isSaving || !props.canSave
|
||||
);
|
||||
const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/40">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Output Preview
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-slate-300">
|
||||
{{ templateLabel }} • {{ productionPixels }}×{{ productionPixels }} px
|
||||
</p>
|
||||
</div>
|
||||
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 shadow-lg shadow-slate-950/40">
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-sky-500/60 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:border-slate-600 disabled:text-slate-500"
|
||||
:disabled="props.isExporting"
|
||||
@click="handleExport"
|
||||
class="w-full rounded-xl bg-emerald-500 px-6 py-4 text-base font-semibold text-emerald-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-500/60 disabled:text-emerald-900/60"
|
||||
:disabled="props.isCheckoutPending"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
{{ props.isExporting ? "Exporting…" : "Generate Files" }}
|
||||
{{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
|
||||
</button>
|
||||
</header>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
|
||||
:class="isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
|
||||
@click="handleSelectView('flat')"
|
||||
>
|
||||
Flat View
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
|
||||
:class="!isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
|
||||
@click="handleSelectView('turntable')"
|
||||
>
|
||||
Turntable View
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 aspect-square overflow-hidden rounded-2xl border border-slate-800 bg-slate-950">
|
||||
<template v-if="props.previewUrl">
|
||||
<div v-if="isFlat" class="h-full w-full">
|
||||
<img
|
||||
:src="props.previewUrl"
|
||||
alt="Slipmat preview flat"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative h-full w-full bg-linear-to-br from-slate-900 via-slate-950 to-black">
|
||||
<img
|
||||
src="/turntable-mockup.svg"
|
||||
alt="Turntable illustration"
|
||||
class="pointer-events-none h-full w-full object-contain"
|
||||
/>
|
||||
<div class="absolute left-[16%] top-[18%] h-[64%] w-[48%] -rotate-2 overflow-hidden rounded-full shadow-xl shadow-black/40">
|
||||
<div class="absolute inset-0 bg-slate-900/40" />
|
||||
<img
|
||||
:src="props.previewUrl"
|
||||
alt="Slipmat preview turntable"
|
||||
class="h-full w-full object-cover opacity-95 mix-blend-screen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-slate-500"
|
||||
v-if="props.checkoutError"
|
||||
class="rounded-xl border border-rose-500/60 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"
|
||||
>
|
||||
No preview yet—start designing!
|
||||
{{ props.checkoutError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Project Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-800 bg-slate-900 px-3 py-2 text-sm text-slate-100 shadow-inner shadow-black/20 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
:value="props.projectName"
|
||||
:disabled="props.isSaving"
|
||||
maxlength="120"
|
||||
placeholder="e.g. Midnight Mix Vol. 1"
|
||||
@input="handleProjectNameInput"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="h-11 rounded-xl bg-emerald-500 px-6 text-sm font-semibold text-emerald-50 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-slate-700/70 disabled:text-slate-400"
|
||||
:disabled="isSaveDisabled"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ props.isSaving ? "Saving…" : "Save to Library" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl bg-slate-800 px-4 py-3 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-800/70 disabled:text-slate-500"
|
||||
:disabled="!props.previewUrl"
|
||||
@click="handleDownloadPreview"
|
||||
>
|
||||
Download Web Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:bg-slate-600/70"
|
||||
:disabled="props.isExporting"
|
||||
@click="handleDownloadProduction"
|
||||
>
|
||||
Download Print-Ready PNG
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,10 @@ const props = defineProps<{
|
||||
onImportImage: (file: File) => Promise<void>;
|
||||
onFillChange: (fill: string) => void;
|
||||
onStrokeChange: (stroke: string) => void;
|
||||
onBackgroundChange: (background: string) => void;
|
||||
activeFill: string | null;
|
||||
activeStroke: string | null;
|
||||
activeBackground: string;
|
||||
canStyleSelection: boolean;
|
||||
zoom: number;
|
||||
minZoom: number;
|
||||
@@ -28,6 +30,7 @@ const emit = defineEmits<{
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const fillValue = ref(props.activeFill ?? "#111827");
|
||||
const strokeValue = ref(props.activeStroke ?? "#3b82f6");
|
||||
const backgroundValue = ref(props.activeBackground ?? "#ffffff");
|
||||
const zoomSliderValue = ref(Math.round(props.zoom * 100));
|
||||
|
||||
watch(
|
||||
@@ -44,6 +47,13 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeBackground,
|
||||
(next) => {
|
||||
backgroundValue.value = next ?? "#ffffff";
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.zoom,
|
||||
(next) => {
|
||||
@@ -88,6 +98,13 @@ const handleStrokeChange = (event: Event) => {
|
||||
props.onStrokeChange(value);
|
||||
};
|
||||
|
||||
const handleBackgroundChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
backgroundValue.value = value;
|
||||
props.onBackgroundChange(value);
|
||||
};
|
||||
|
||||
const handleZoomInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = Number(input.value);
|
||||
@@ -102,53 +119,117 @@ const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Canvas Tools
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800/60 bg-slate-900/50 px-4 py-3">
|
||||
<!-- Add Elements Group -->
|
||||
<div class="flex items-center gap-1 border-r border-slate-700/50 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-red-500/40 px-3 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/10"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-lg font-bold text-slate-300 transition hover:bg-slate-800"
|
||||
title="Add Text"
|
||||
@click="props.onAddText"
|
||||
>
|
||||
T
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
|
||||
title="Add Circle"
|
||||
@click="props.onAddCircle"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
|
||||
title="Add Rectangle"
|
||||
@click="props.onAddRectangle"
|
||||
>
|
||||
▭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-300 transition hover:bg-slate-800"
|
||||
title="Upload Image"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color Pickers Group -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-700/50 pr-3">
|
||||
<label class="flex items-center gap-1.5" title="Canvas Background">
|
||||
<span class="text-xs text-slate-400">Canvas</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
|
||||
:value="backgroundValue"
|
||||
@input="handleBackgroundChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Fill Color">
|
||||
<span class="text-xs text-slate-400">Fill</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="fillValue"
|
||||
@input="handleFillChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Stroke Color">
|
||||
<span class="text-xs text-slate-400">Stroke</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-700/70 bg-slate-800/80 p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="strokeValue"
|
||||
@input="handleStrokeChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-700/50 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-700/60 bg-slate-800/80 text-sm font-bold text-slate-100 transition hover:bg-slate-700"
|
||||
title="Zoom Out"
|
||||
@click="props.onZoomOut"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span class="min-w-12 text-center text-xs font-medium text-slate-300">
|
||||
{{ zoomLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-700/60 bg-slate-800/80 text-sm font-bold text-slate-100 transition hover:bg-slate-700"
|
||||
title="Zoom In"
|
||||
@click="props.onZoomIn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-slate-700/60 bg-slate-800/80 px-2 py-1 text-xs font-medium text-slate-200 transition hover:bg-slate-700"
|
||||
title="Reset Zoom"
|
||||
@click="props.onZoomReset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded border border-red-500/40 px-3 py-1.5 text-xs font-medium text-red-300 transition hover:bg-red-500/10"
|
||||
@click="props.onClear"
|
||||
>
|
||||
Clear Canvas
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
@click="props.onAddText"
|
||||
>
|
||||
<span class="mb-2 text-3xl font-semibold">T</span>
|
||||
<span class="text-xs uppercase tracking-wide">Add Text</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
@click="props.onAddCircle"
|
||||
>
|
||||
<span class="mb-2 text-3xl font-semibold">●</span>
|
||||
<span class="text-xs uppercase tracking-wide">Add Circle</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
@click="props.onAddRectangle"
|
||||
>
|
||||
<span class="mb-2 text-3xl font-semibold">▭</span>
|
||||
<span class="text-xs uppercase tracking-wide">Add Rectangle</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<span class="mb-2 text-3xl font-semibold">⇪</span>
|
||||
<span class="text-xs uppercase tracking-wide">Upload Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -156,83 +237,6 @@ const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Styling
|
||||
</h4>
|
||||
<span
|
||||
v-if="stylingDisabled"
|
||||
class="text-[11px] font-medium uppercase tracking-wide text-slate-500"
|
||||
>
|
||||
Select an object
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Fill
|
||||
<input
|
||||
type="color"
|
||||
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||||
:disabled="stylingDisabled"
|
||||
:value="fillValue"
|
||||
@input="handleFillChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Stroke
|
||||
<input
|
||||
type="color"
|
||||
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||||
:disabled="stylingDisabled"
|
||||
:value="strokeValue"
|
||||
@input="handleStrokeChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
View
|
||||
</h4>
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-slate-300">
|
||||
{{ zoomLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||||
@click="props.onZoomOut"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
class="flex-1 accent-sky-500"
|
||||
:min="Math.round(props.minZoom * 100)"
|
||||
:max="Math.round(props.maxZoom * 100)"
|
||||
step="5"
|
||||
:value="zoomSliderValue"
|
||||
@input="handleZoomInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||||
@click="props.onZoomIn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 w-full rounded-lg border border-slate-700/60 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
@click="props.onZoomReset"
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const handleSelect = (templateId: string) => {
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Pick the vinyl size and print spec that matches this order.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-1">
|
||||
<button
|
||||
v-for="template in props.templates"
|
||||
:key="template.id"
|
||||
@@ -44,7 +44,7 @@ const handleSelect = (templateId: string) => {
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-slate-300">
|
||||
<dl class="mt-3 grid grid-cols-4 gap-x-3 gap-y-2 text-xs text-slate-300">
|
||||
<div>
|
||||
<dt class="text-slate-500">Diameter</dt>
|
||||
<dd>{{ template.diameterInches }}"</dd>
|
||||
|
||||
290
app/composables/useAuth.ts
Normal file
290
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
GoogleAuthProvider,
|
||||
signOut as firebaseSignOut,
|
||||
onAuthStateChanged,
|
||||
getAuth,
|
||||
createUserWithEmailAndPassword,
|
||||
} from "firebase/auth";
|
||||
import { getApp, getApps, initializeApp } from "firebase/app";
|
||||
import type { User } from "firebase/auth";
|
||||
|
||||
export const useAuth = () => {
|
||||
const user = useState<User | null>("auth-user", () => null);
|
||||
const isLoading = useState<boolean>("auth-loading", () => true);
|
||||
const error = useState<string | null>("auth-error", () => null);
|
||||
const firebaseReady = useState<boolean>("firebase-ready", () => false);
|
||||
const listenerRegistered = useState<boolean>(
|
||||
"auth-listener-registered",
|
||||
() => false
|
||||
);
|
||||
const backendUser = useState<Record<string, any> | null>(
|
||||
"auth-backend-user",
|
||||
() => null
|
||||
);
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const ensureFirebaseApp = () => {
|
||||
if (!process.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!firebaseReady.value) {
|
||||
const firebaseConfig = {
|
||||
apiKey: config.public.firebaseApiKey,
|
||||
authDomain: config.public.firebaseAuthDomain,
|
||||
projectId: config.public.firebaseProjectId,
|
||||
storageBucket: config.public.firebaseStorageBucket,
|
||||
messagingSenderId: config.public.firebaseMessagingSenderId,
|
||||
appId: config.public.firebaseAppId,
|
||||
...(config.public.firebaseMeasurementId
|
||||
? { measurementId: config.public.firebaseMeasurementId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (getApps().length === 0) {
|
||||
initializeApp(firebaseConfig);
|
||||
}
|
||||
|
||||
firebaseReady.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
return getApp();
|
||||
} catch (err) {
|
||||
console.error("[useAuth] Failed to get Firebase app instance:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthInstance = () => {
|
||||
const app = ensureFirebaseApp();
|
||||
return app ? getAuth(app) : null;
|
||||
};
|
||||
|
||||
const authenticateWithBackend = async (idToken: string) => {
|
||||
try {
|
||||
const response = await $fetch<{
|
||||
token?: string;
|
||||
user?: Record<string, any> | null;
|
||||
}>("/auth/login", {
|
||||
baseURL: config.public.backendUrl,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: {
|
||||
idToken,
|
||||
},
|
||||
});
|
||||
|
||||
backendUser.value = response?.user ?? null;
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error("Backend authentication failed:", err);
|
||||
backendUser.value = null;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const syncBackendWithToken = async (tokenProvider: () => Promise<string>) => {
|
||||
try {
|
||||
const idToken = await tokenProvider();
|
||||
await authenticateWithBackend(idToken);
|
||||
} catch (err) {
|
||||
console.warn("[useAuth] Failed to sync backend session", err);
|
||||
}
|
||||
};
|
||||
|
||||
const registerListener = () => {
|
||||
if (!process.client || listenerRegistered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = getAuthInstance();
|
||||
if (auth) {
|
||||
const existingUser = auth.currentUser;
|
||||
if (existingUser && !user.value) {
|
||||
user.value = existingUser;
|
||||
isLoading.value = false;
|
||||
syncBackendWithToken(() => existingUser.getIdToken());
|
||||
}
|
||||
|
||||
listenerRegistered.value = true;
|
||||
onAuthStateChanged(auth, (firebaseUser) => {
|
||||
user.value = firebaseUser;
|
||||
isLoading.value = false;
|
||||
if (firebaseUser) {
|
||||
syncBackendWithToken(() => firebaseUser.getIdToken());
|
||||
} else {
|
||||
backendUser.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useAuth] Failed to initialize auth listener:", err);
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const initAuth = () => {
|
||||
registerListener();
|
||||
};
|
||||
|
||||
// Eagerly register listener when composable is used in client context
|
||||
if (process.client) {
|
||||
registerListener();
|
||||
}
|
||||
|
||||
// Sign in with email and password
|
||||
const signInWithEmail = async (email: string, password: string) => {
|
||||
try {
|
||||
error.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
const auth = getAuthInstance();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
const userCredential = await signInWithEmailAndPassword(
|
||||
auth,
|
||||
email,
|
||||
password
|
||||
);
|
||||
const idToken = await userCredential.user.getIdToken();
|
||||
|
||||
try {
|
||||
await authenticateWithBackend(idToken);
|
||||
} catch (backendErr) {
|
||||
console.warn(
|
||||
"[useAuth] Backend authentication failed after email login:",
|
||||
backendErr
|
||||
);
|
||||
}
|
||||
|
||||
user.value = userCredential.user;
|
||||
registerListener();
|
||||
|
||||
return userCredential.user;
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Sign in with Google
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
error.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
const auth = getAuthInstance();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
const provider = new GoogleAuthProvider();
|
||||
const userCredential = await signInWithPopup(auth, provider);
|
||||
const idToken = await userCredential.user.getIdToken();
|
||||
|
||||
try {
|
||||
await authenticateWithBackend(idToken);
|
||||
} catch (backendErr) {
|
||||
console.warn(
|
||||
"[useAuth] Backend authentication failed after Google login:",
|
||||
backendErr
|
||||
);
|
||||
}
|
||||
|
||||
user.value = userCredential.user;
|
||||
registerListener();
|
||||
|
||||
return userCredential.user;
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const registerWithEmail = async (email: string, password: string) => {
|
||||
try {
|
||||
error.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
const auth = getAuthInstance();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
const userCredential = await createUserWithEmailAndPassword(
|
||||
auth,
|
||||
email,
|
||||
password
|
||||
);
|
||||
const idToken = await userCredential.user.getIdToken();
|
||||
|
||||
try {
|
||||
await authenticateWithBackend(idToken);
|
||||
} catch (backendErr) {
|
||||
console.warn(
|
||||
"[useAuth] Backend authentication failed after registration:",
|
||||
backendErr
|
||||
);
|
||||
}
|
||||
|
||||
user.value = userCredential.user;
|
||||
registerListener();
|
||||
|
||||
return userCredential.user;
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Sign out
|
||||
const signOut = async () => {
|
||||
try {
|
||||
const auth = getAuthInstance();
|
||||
if (!auth) {
|
||||
throw new Error("Firebase not initialized");
|
||||
}
|
||||
|
||||
await firebaseSignOut(auth);
|
||||
user.value = null;
|
||||
backendUser.value = null;
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current user's ID token
|
||||
const getIdToken = async () => {
|
||||
if (!user.value) return null;
|
||||
return await user.value.getIdToken();
|
||||
};
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
backendUser: readonly(backendUser),
|
||||
initAuth,
|
||||
signInWithEmail,
|
||||
signInWithGoogle,
|
||||
registerWithEmail,
|
||||
signOut,
|
||||
getIdToken,
|
||||
};
|
||||
};
|
||||
1
app/composables/useFirebaseStorage.ts
Normal file
1
app/composables/useFirebaseStorage.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
1
app/composables/useLoginModal.ts
Normal file
1
app/composables/useLoginModal.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const useLoginModal = () => useState<boolean>('login-modal-open', () => false);
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuth0 } from "@auth0/auth0-vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const router = useRouter();
|
||||
const auth0 = useAuth0();
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await auth0.handleRedirectCallback();
|
||||
const target = result?.appState?.target ?? "/";
|
||||
await router.replace(target);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex min-h-screen flex-col items-center justify-center bg-slate-950 px-4 text-center text-slate-100"
|
||||
>
|
||||
<div class="max-w-sm space-y-4">
|
||||
<h1 class="text-lg font-semibold">Signing you in…</h1>
|
||||
<p class="text-sm text-slate-400">
|
||||
Please wait while we complete authentication and return you to your work.
|
||||
</p>
|
||||
<p v-if="errorMessage" class="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<div v-else class="mx-auto h-12 w-12 animate-spin rounded-full border-4 border-slate-800 border-t-sky-500" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
235
app/pages/checkout/success.vue
Normal file
235
app/pages/checkout/success.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionId = computed(() => {
|
||||
const raw = route.query.session_id
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0]
|
||||
}
|
||||
return typeof raw === 'string' ? raw : null
|
||||
})
|
||||
|
||||
const { data: session, pending, error } = useAsyncData('checkout-session', async () => {
|
||||
if (!sessionId.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await $fetch(`/api/checkout/${sessionId.value}`)
|
||||
}, {
|
||||
watch: [sessionId],
|
||||
})
|
||||
|
||||
const amountLabel = computed(() => {
|
||||
if (!session.value?.amountTotal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currencyCode = session.value.currency?.toUpperCase() ?? 'USD'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(session.value.amountTotal / 100)
|
||||
})
|
||||
|
||||
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
|
||||
const supportsClipboard = ref(false)
|
||||
const transactionStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const transactionMessage = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
supportsClipboard.value = typeof navigator !== 'undefined' && !!navigator.clipboard
|
||||
})
|
||||
|
||||
watch(
|
||||
() => session.value,
|
||||
async (sessionValue) => {
|
||||
if (!process.client) {
|
||||
return
|
||||
}
|
||||
if (transactionStatus.value !== 'idle') {
|
||||
return
|
||||
}
|
||||
if (!sessionId.value || !sessionValue?.id) {
|
||||
return
|
||||
}
|
||||
if (sessionValue.paymentStatus !== 'paid') {
|
||||
return
|
||||
}
|
||||
|
||||
transactionStatus.value = 'saving'
|
||||
|
||||
try {
|
||||
await $fetch('/api/transactions', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
stripeSessionId: sessionValue.id,
|
||||
designId: sessionValue.metadata?.designId ?? `unknown-${sessionValue.id}`,
|
||||
templateId: sessionValue.metadata?.templateId ?? null,
|
||||
amount: sessionValue.amountTotal
|
||||
? (sessionValue.amountTotal / 100).toFixed(2)
|
||||
: "0",
|
||||
currency: sessionValue.currency ?? 'usd',
|
||||
customerEmail: sessionValue.customerEmail ?? null,
|
||||
customerDetails: sessionValue.customerDetails,
|
||||
},
|
||||
})
|
||||
transactionStatus.value = 'saved'
|
||||
} catch (err: any) {
|
||||
console.error('Failed to persist transaction', err)
|
||||
transactionStatus.value = 'error'
|
||||
transactionMessage.value = err?.message ?? 'Unable to record this transaction.'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const copySessionId = async () => {
|
||||
if (!supportsClipboard.value || !sessionId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId.value)
|
||||
copyStatus.value = 'copied'
|
||||
setTimeout(() => {
|
||||
copyStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy session id', err)
|
||||
copyStatus.value = 'error'
|
||||
setTimeout(() => {
|
||||
copyStatus.value = 'idle'
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const goToDesigner = () => {
|
||||
const metadata = (session.value?.metadata ?? {}) as Record<string, unknown>
|
||||
const designId = typeof metadata.designId === 'string' ? metadata.designId : null
|
||||
|
||||
if (designId) {
|
||||
router.push({ path: '/', query: { designId } })
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-2xl flex-col gap-6 px-4 pt-16 text-slate-100">
|
||||
<header class="space-y-4 text-center">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-300">
|
||||
<svg viewBox="0 0 24 24" class="h-10 w-10 fill-current">
|
||||
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.7 7.3-5.2 5.2a1 1 0 0 1-1.4 0l-2-2a1 1 0 0 1 1.4-1.4l1.3 1.29 4.5-4.49a1 1 0 0 1 1.4 1.4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold">Payment Confirmed</h1>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
Thank you for purchasing your custom slipmat design. We've received your payment and sent a confirmation email.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!sessionId" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<p class="text-sm text-slate-300">
|
||||
We couldn't find a Stripe session ID in the URL. Please return to the designer and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded-xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-500"
|
||||
@click="goToDesigner"
|
||||
>
|
||||
Back to Designer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h2 class="text-lg font-semibold text-white">Order Summary</h2>
|
||||
<dl class="mt-4 space-y-2 text-sm text-slate-300">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Total Paid</dt>
|
||||
<dd>
|
||||
<span v-if="pending" class="text-slate-500">Loading...</span>
|
||||
<span v-else-if="amountLabel">{{ amountLabel }}</span>
|
||||
<span v-else class="text-slate-500">Pending</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Payment Status</dt>
|
||||
<dd>
|
||||
<span v-if="pending" class="text-slate-500">Loading...</span>
|
||||
<span v-else-if="session?.paymentStatus">
|
||||
{{ session.paymentStatus === 'paid' ? 'Paid' : session.paymentStatus }}
|
||||
</span>
|
||||
<span v-else class="text-slate-500">Unknown</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="session?.customerEmail" class="flex items-center justify-between">
|
||||
<dt>Receipt sent to</dt>
|
||||
<dd>{{ session.customerEmail }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Session ID</dt>
|
||||
<dd class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span class="truncate max-w-[180px] sm:max-w-xs" :title="sessionId">{{ sessionId }}</span>
|
||||
<button
|
||||
v-if="supportsClipboard"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-700/70 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-200 transition hover:border-slate-500 hover:text-white"
|
||||
@click="copySessionId"
|
||||
>
|
||||
{{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div v-if="transactionStatus === 'saving'" class="mt-4 rounded-xl border border-slate-700/60 bg-slate-800/70 px-4 py-3 text-sm text-slate-200">
|
||||
Recording your transaction...
|
||||
</div>
|
||||
<div v-else-if="transactionStatus === 'error'" class="mt-4 rounded-xl border border-rose-500/50 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{{ transactionMessage || 'We could not record this transaction. Please contact support with your session ID.' }}
|
||||
</div>
|
||||
<div v-else-if="transactionStatus === 'saved'" class="mt-4 rounded-xl border border-emerald-500/50 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
|
||||
Transaction stored safely.
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-xl border border-rose-500/50 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h2 class="text-lg font-semibold text-white">What's next?</h2>
|
||||
<ul class="mt-4 space-y-3 text-sm text-slate-300">
|
||||
<li>
|
||||
We'll email you a confirmation that includes your payment details and a link to download the production-ready files once they're generated.
|
||||
</li>
|
||||
<li>
|
||||
Need to tweak the design? Use the button below to reopen this project; your saved layout will load automatically.
|
||||
</li>
|
||||
<li>
|
||||
Have questions? Reply directly to the confirmation email and our team will help out.
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-6 w-full rounded-xl bg-emerald-500 px-4 py-3 text-sm font-semibold text-emerald-950 transition hover:bg-emerald-400"
|
||||
@click="goToDesigner"
|
||||
>
|
||||
Back to Designer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
441
app/pages/designer.vue
Normal file
441
app/pages/designer.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
||||
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
||||
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
||||
|
||||
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||||
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
|
||||
|
||||
const {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
loadDesign,
|
||||
displaySize,
|
||||
templateLabel,
|
||||
productionPixelSize,
|
||||
previewUrl,
|
||||
registerCanvas,
|
||||
unregisterCanvas,
|
||||
addTextbox,
|
||||
addShape,
|
||||
addImageFromFile,
|
||||
clearDesign,
|
||||
downloadPreview,
|
||||
downloadProduction,
|
||||
exportDesign,
|
||||
isExporting,
|
||||
activeFillColor,
|
||||
activeStrokeColor,
|
||||
canStyleSelection,
|
||||
setActiveFillColor,
|
||||
setActiveStrokeColor,
|
||||
setBackgroundColor,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} = useSlipmatDesigner();
|
||||
|
||||
const DESIGN_PRICE_USD = 39.99;
|
||||
|
||||
const { user, backendUser, initAuth, isLoading } = useAuth();
|
||||
const loginModal = useLoginModal();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const activeDesignId = ref<string | null>(
|
||||
typeof route.query.designId === "string" ? route.query.designId : null
|
||||
);
|
||||
const loadedDesignId = ref<string | null>(null);
|
||||
const isDesignLoading = ref(false);
|
||||
const isCheckoutPending = ref(false);
|
||||
const checkoutError = ref<string | null>(null);
|
||||
const lastExportedDesign = ref<ExportedDesign | null>(null);
|
||||
|
||||
type LoadDesignInput = Parameters<typeof loadDesign>[0];
|
||||
|
||||
type StorageUploadResponse = {
|
||||
bucket: string;
|
||||
objectName: string;
|
||||
publicUrl: string;
|
||||
presignedUrl?: string | null;
|
||||
presignedUrlExpiresIn?: number | null;
|
||||
};
|
||||
|
||||
const uploadDesignAsset = async (
|
||||
file: Blob,
|
||||
filename: string,
|
||||
options?: { prefix?: string; bucket?: string }
|
||||
): Promise<StorageUploadResponse> => {
|
||||
if (!process.client) {
|
||||
throw new Error("Asset uploads can only run in the browser context.");
|
||||
}
|
||||
|
||||
const backendUrl = config.public.backendUrl;
|
||||
if (!backendUrl) {
|
||||
throw new Error("Backend URL is not configured.");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, filename);
|
||||
if (options?.prefix) {
|
||||
formData.append("prefix", options.prefix);
|
||||
}
|
||||
if (options?.bucket) {
|
||||
formData.append("bucket", options.bucket);
|
||||
}
|
||||
|
||||
return await $fetch<StorageUploadResponse>("/storage/upload", {
|
||||
baseURL: backendUrl,
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const persistDesign = async (designId: string, design: ExportedDesign) => {
|
||||
const safeDesignIdBase = designId.replace(/[^a-zA-Z0-9_-]/g, "-") || "design";
|
||||
const assetBasePath = `designs/${safeDesignIdBase}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (!design.previewBlob || !design.productionBlob) {
|
||||
throw new Error("Design assets are missing; please export again.");
|
||||
}
|
||||
|
||||
const canvasJsonString =
|
||||
typeof design.canvasJson === "string"
|
||||
? design.canvasJson
|
||||
: JSON.stringify(design.canvasJson);
|
||||
|
||||
const prefix = `${assetBasePath}/`;
|
||||
|
||||
const [previewUpload, productionUpload, canvasUpload] = await Promise.all([
|
||||
uploadDesignAsset(design.previewBlob, `preview-${timestamp}.png`, {
|
||||
prefix,
|
||||
}),
|
||||
uploadDesignAsset(design.productionBlob, `production-${timestamp}.png`, {
|
||||
prefix,
|
||||
}),
|
||||
uploadDesignAsset(
|
||||
new Blob([canvasJsonString], { type: "application/json" }),
|
||||
`canvas-${timestamp}.json`,
|
||||
{ prefix }
|
||||
),
|
||||
]);
|
||||
|
||||
// Get Firebase ID token for authentication
|
||||
const idToken = user.value ? await user.value.getIdToken() : null;
|
||||
|
||||
await $fetch("/api/designs", {
|
||||
method: "POST",
|
||||
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
|
||||
body: {
|
||||
designId,
|
||||
templateId: design.templateId,
|
||||
ownerEmail: user.value?.email ?? null,
|
||||
ownerId: backendUser.value?.id ?? null,
|
||||
previewUrl: previewUpload.publicUrl,
|
||||
productionUrl: productionUpload.publicUrl,
|
||||
canvasJson: canvasUpload.publicUrl,
|
||||
metadata: {
|
||||
designName: templateLabel.value,
|
||||
storage: {
|
||||
preview: {
|
||||
objectName: previewUpload.objectName,
|
||||
bucket: previewUpload.bucket,
|
||||
},
|
||||
production: {
|
||||
objectName: productionUpload.objectName,
|
||||
bucket: productionUpload.bucket,
|
||||
},
|
||||
canvas: {
|
||||
objectName: canvasUpload.objectName,
|
||||
bucket: canvasUpload.bucket,
|
||||
},
|
||||
},
|
||||
storagePaths: {
|
||||
preview: previewUpload.objectName,
|
||||
production: productionUpload.objectName,
|
||||
canvas: canvasUpload.objectName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
previewUrl: previewUpload.publicUrl,
|
||||
productionUrl: productionUpload.publicUrl,
|
||||
canvasJsonUrl: canvasUpload.publicUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const loadDesignById = async (designId: string) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDesignLoading.value = true;
|
||||
try {
|
||||
const design = await $fetch<{
|
||||
designId?: string;
|
||||
templateId?: string | null;
|
||||
previewUrl?: string | null;
|
||||
productionUrl?: string | null;
|
||||
canvasJson?: unknown;
|
||||
}>(`/api/designs/${designId}`);
|
||||
|
||||
if (!design?.canvasJson) {
|
||||
throw new Error("Saved design is missing canvas data.");
|
||||
}
|
||||
|
||||
let canvasPayload: LoadDesignInput["canvasJson"] =
|
||||
design.canvasJson as LoadDesignInput["canvasJson"];
|
||||
|
||||
if (typeof design.canvasJson === "string") {
|
||||
const trimmed = design.canvasJson.trim();
|
||||
const isRemoteSource = /^https?:\/\//i.test(trimmed);
|
||||
|
||||
if (isRemoteSource) {
|
||||
const response = await fetch(trimmed);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to download saved canvas data.");
|
||||
}
|
||||
canvasPayload =
|
||||
(await response.text()) as LoadDesignInput["canvasJson"];
|
||||
} else {
|
||||
canvasPayload = trimmed as LoadDesignInput["canvasJson"];
|
||||
}
|
||||
}
|
||||
|
||||
await loadDesign({
|
||||
templateId: design.templateId ?? null,
|
||||
canvasJson: canvasPayload,
|
||||
previewUrl: design.previewUrl ?? null,
|
||||
});
|
||||
|
||||
loadedDesignId.value = designId;
|
||||
lastExportedDesign.value = null;
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load saved design", error);
|
||||
} finally {
|
||||
isDesignLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.query.designId,
|
||||
(value) => {
|
||||
const nextId = typeof value === "string" ? value : null;
|
||||
if (nextId !== activeDesignId.value) {
|
||||
activeDesignId.value = nextId;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
activeDesignId,
|
||||
(designId) => {
|
||||
if (!designId || designId === loadedDesignId.value) {
|
||||
return;
|
||||
}
|
||||
loadDesignById(designId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
selectTemplate(templateId);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const result = await exportDesign();
|
||||
if (result) {
|
||||
lastExportedDesign.value = result;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
initAuth();
|
||||
|
||||
if (isLoading.value) {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && isLoading.value && !user.value;
|
||||
attempt += 1
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
loginModal.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
checkoutError.value = null;
|
||||
isCheckoutPending.value = true;
|
||||
|
||||
try {
|
||||
let exportResult = await exportDesign();
|
||||
if (!exportResult) {
|
||||
exportResult = lastExportedDesign.value;
|
||||
}
|
||||
if (!exportResult) {
|
||||
throw new Error("Unable to export the current design. Please try again.");
|
||||
}
|
||||
lastExportedDesign.value = exportResult;
|
||||
|
||||
const designId =
|
||||
activeDesignId.value ??
|
||||
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `design-${Date.now()}`);
|
||||
|
||||
await persistDesign(designId, exportResult);
|
||||
|
||||
activeDesignId.value = designId;
|
||||
loadedDesignId.value = designId;
|
||||
|
||||
if (
|
||||
typeof route.query.designId !== "string" ||
|
||||
route.query.designId !== designId
|
||||
) {
|
||||
await router.replace({ query: { designId } });
|
||||
}
|
||||
|
||||
const successUrlTemplate = `${window.location.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = window.location.href;
|
||||
|
||||
const session = await $fetch<{ id: string; url?: string | null }>(
|
||||
"/api/checkout.session",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
designId,
|
||||
templateId: exportResult.templateId,
|
||||
designName: templateLabel.value,
|
||||
amount: DESIGN_PRICE_USD,
|
||||
currency: "usd",
|
||||
successUrl: successUrlTemplate,
|
||||
cancelUrl,
|
||||
customerEmail: user.value?.email ?? undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!session?.id) {
|
||||
throw new Error("Stripe session could not be created.");
|
||||
}
|
||||
|
||||
if (session.url) {
|
||||
window.location.href = session.url;
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackRedirect = successUrlTemplate.replace(
|
||||
"{CHECKOUT_SESSION_ID}",
|
||||
session.id
|
||||
);
|
||||
window.location.href = fallbackRedirect;
|
||||
} catch (err: any) {
|
||||
console.error("Checkout failed", err);
|
||||
checkoutError.value =
|
||||
err?.message ?? "Unable to start checkout. Please try again.";
|
||||
} finally {
|
||||
isCheckoutPending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||
<AppNavbar />
|
||||
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">
|
||||
Slipmatz Designer
|
||||
</p>
|
||||
<h1 class="text-3xl font-bold text-white sm:text-4xl">
|
||||
Craft custom slipmats ready for the pressing plant.
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 flex flex-col gap-8 lg:grid lg:grid-cols-[320px_minmax(0,1fr)] lg:gap-6">
|
||||
<!-- Left Sidebar - Template Picker and Preview (together on desktop, separate on mobile) -->
|
||||
<div class="contents lg:block lg:space-y-6">
|
||||
<div class="order-1">
|
||||
<TemplatePicker
|
||||
:templates="templates"
|
||||
:selected-template-id="selectedTemplate.id"
|
||||
@select="handleTemplateSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="order-3">
|
||||
<DesignerPreview
|
||||
:is-checkout-pending="isCheckoutPending"
|
||||
:checkout-price="DESIGN_PRICE_USD"
|
||||
:checkout-error="checkoutError"
|
||||
@checkout="handleCheckout"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Designer Canvas - Second on mobile, right column on desktop -->
|
||||
<div class="order-2 flex flex-col gap-6 lg:order-0">
|
||||
<div
|
||||
class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black shadow-2xl shadow-slate-950/60"
|
||||
>
|
||||
<DesignerToolbar
|
||||
:on-add-text="addTextbox"
|
||||
:on-add-circle="() => addShape('circle')"
|
||||
:on-add-rectangle="() => addShape('rect')"
|
||||
:on-clear="clearDesign"
|
||||
:on-import-image="addImageFromFile"
|
||||
:on-fill-change="setActiveFillColor"
|
||||
:on-stroke-change="setActiveStrokeColor"
|
||||
:on-background-change="setBackgroundColor"
|
||||
:active-fill="activeFillColor"
|
||||
:active-stroke="activeStrokeColor"
|
||||
:active-background="selectedTemplate.backgroundColor"
|
||||
:can-style-selection="canStyleSelection"
|
||||
:zoom="zoomLevel"
|
||||
:min-zoom="minZoom"
|
||||
:max-zoom="maxZoom"
|
||||
:on-zoom-change="setZoom"
|
||||
:on-zoom-in="zoomIn"
|
||||
:on-zoom-out="zoomOut"
|
||||
:on-zoom-reset="resetZoom"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<DesignerCanvas
|
||||
:size="displaySize"
|
||||
:background-color="selectedTemplate.backgroundColor"
|
||||
:register-canvas="registerCanvas"
|
||||
:unregister-canvas="unregisterCanvas"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-slate-400">
|
||||
Safe zone and bleed guides update automatically when you switch
|
||||
templates. Use the toolbar to layer text, shapes, and imagery
|
||||
inside the circular boundary.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,344 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useAuth0 } from "@auth0/auth0-vue";
|
||||
import { useRuntimeConfig } from "nuxt/app";
|
||||
const router = useRouter();
|
||||
|
||||
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
||||
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
||||
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
||||
|
||||
import { useDesignPersistence } from "../../composables/useDesignPersistence";
|
||||
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||||
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const auth0 = process.client ? useAuth0() : null;
|
||||
|
||||
const {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
displaySize,
|
||||
templateLabel,
|
||||
productionPixelSize,
|
||||
previewUrl,
|
||||
registerCanvas,
|
||||
unregisterCanvas,
|
||||
addTextbox,
|
||||
addShape,
|
||||
addImageFromFile,
|
||||
clearDesign,
|
||||
downloadPreview,
|
||||
downloadProduction,
|
||||
exportDesign,
|
||||
isExporting,
|
||||
activeFillColor,
|
||||
activeStrokeColor,
|
||||
canStyleSelection,
|
||||
setActiveFillColor,
|
||||
setActiveStrokeColor,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
} = useSlipmatDesigner();
|
||||
|
||||
const {
|
||||
designs,
|
||||
fetchDesigns,
|
||||
saveDesign,
|
||||
isSaving,
|
||||
isLoading: isLibraryLoading,
|
||||
lastError,
|
||||
isAuthenticated,
|
||||
} = useDesignPersistence();
|
||||
|
||||
const projectName = ref("Untitled Slipmat");
|
||||
const saveMessage = ref<string | null>(null);
|
||||
const saveError = ref<string | null>(null);
|
||||
const activeDesignId = ref<string | null>(null);
|
||||
|
||||
const userProfile = computed(() => auth0?.user.value ?? null);
|
||||
|
||||
const canSave = computed(
|
||||
() => isAuthenticated.value && !!previewUrl.value && !isExporting.value
|
||||
);
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
selectTemplate(templateId);
|
||||
const startDesigning = () => {
|
||||
router.push('/designer');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
await exportDesign();
|
||||
};
|
||||
|
||||
const handleProjectNameUpdate = (value: string) => {
|
||||
projectName.value = value;
|
||||
};
|
||||
|
||||
const formatTimestamp = (value: string | null) => {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Unknown";
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const handleSaveDesign = async () => {
|
||||
if (!canSave.value) {
|
||||
return;
|
||||
}
|
||||
saveError.value = null;
|
||||
|
||||
try {
|
||||
const exported = await exportDesign();
|
||||
if (!exported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await saveDesign({
|
||||
id: activeDesignId.value ?? undefined,
|
||||
name:
|
||||
projectName.value.trim() ||
|
||||
`Slipmat ${new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date())}`,
|
||||
design: exported,
|
||||
});
|
||||
|
||||
activeDesignId.value = saved.id;
|
||||
saveMessage.value = "Design saved to your library.";
|
||||
setTimeout(() => {
|
||||
saveMessage.value = null;
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
saveError.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!auth0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await auth0.logout({
|
||||
logoutParams: {
|
||||
returnTo:
|
||||
runtimeConfig.public.auth0?.baseUrl ??
|
||||
(process.client ? window.location.origin : undefined),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (process.client && auth0) {
|
||||
watch(
|
||||
() => auth0.isAuthenticated.value,
|
||||
async (authenticated) => {
|
||||
if (authenticated) {
|
||||
try {
|
||||
await fetchDesigns();
|
||||
} catch (error) {
|
||||
// Surface error through reactive ref
|
||||
saveError.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} else {
|
||||
designs.value = [];
|
||||
activeDesignId.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(lastError, (value) => {
|
||||
if (value) {
|
||||
saveError.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth0?.isAuthenticated.value) {
|
||||
try {
|
||||
await fetchDesigns();
|
||||
} catch (error) {
|
||||
saveError.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
||||
<header class="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">
|
||||
Slipmatz Designer
|
||||
</p>
|
||||
<h1 class="text-3xl font-bold text-white sm:text-4xl">
|
||||
Craft custom slipmats ready for the pressing plant.
|
||||
<div class="relative min-h-screen overflow-hidden bg-linear-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-20">
|
||||
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(71 85 105) 1px, transparent 0); background-size: 40px 40px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="relative z-20 flex min-h-screen flex-col px-4 py-12 md:px-8 lg:px-16">
|
||||
<!-- Top Section - Split Layout -->
|
||||
<div class="flex flex-1 flex-col items-center justify-between gap-8 lg:flex-row lg:gap-16">
|
||||
<!-- Left Side - Title & Description -->
|
||||
<div class="w-full space-y-8 text-center lg:w-1/2 lg:text-left">
|
||||
<h1 class="text-6xl font-bold tracking-tight text-white drop-shadow-2xl sm:text-7xl md:text-8xl">
|
||||
Slipmatz
|
||||
</h1>
|
||||
<p class="max-w-3xl text-base text-slate-300">
|
||||
Pick a template, drop in artwork, and we’ll generate both a high-fidelity
|
||||
preview and a print-ready PNG at exact specs. Everything stays within a
|
||||
circular safe zone to ensure clean results on vinyl.
|
||||
<div class="space-y-4">
|
||||
<p class="text-2xl font-semibold text-sky-400 drop-shadow-lg sm:text-3xl md:text-4xl">
|
||||
Design custom slipmats for your vinyl
|
||||
</p>
|
||||
|
||||
<!-- Turntable for mobile view -->
|
||||
<div class="relative mx-auto my-8 block lg:hidden">
|
||||
<div class="relative mx-auto h-[280px] w-[280px] sm:h-80 sm:w-80">
|
||||
<img
|
||||
src="/SLIPMATZ.svg"
|
||||
alt="Slipmatz Logo"
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base text-slate-300 drop-shadow-lg sm:text-lg">
|
||||
Create professional, print-ready slipmat designs in minutes
|
||||
</p>
|
||||
<p class="text-sm text-slate-400 drop-shadow-md sm:text-base">
|
||||
Perfect for DJ's, record labels, and vinyl enthusiast.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="userProfile"
|
||||
class="flex items-center gap-3 rounded-2xl border border-slate-800/70 bg-slate-900/70 px-4 py-3 shadow-lg shadow-slate-950/40"
|
||||
>
|
||||
<div class="text-right">
|
||||
<p class="text-[0.7rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
Signed in
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ userProfile.name ?? userProfile.email ?? "Authenticated" }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-sky-500/60 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:bg-sky-500/10"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div class="space-y-6">
|
||||
<TemplatePicker
|
||||
:templates="templates"
|
||||
:selected-template-id="selectedTemplate.id"
|
||||
@select="handleTemplateSelect"
|
||||
/>
|
||||
|
||||
<DesignerToolbar
|
||||
:on-add-text="addTextbox"
|
||||
:on-add-circle="() => addShape('circle')"
|
||||
:on-add-rectangle="() => addShape('rect')"
|
||||
:on-clear="clearDesign"
|
||||
:on-import-image="addImageFromFile"
|
||||
:on-fill-change="setActiveFillColor"
|
||||
:on-stroke-change="setActiveStrokeColor"
|
||||
:active-fill="activeFillColor"
|
||||
:active-stroke="activeStrokeColor"
|
||||
:can-style-selection="canStyleSelection"
|
||||
:zoom="zoomLevel"
|
||||
:min-zoom="minZoom"
|
||||
:max-zoom="maxZoom"
|
||||
:on-zoom-change="setZoom"
|
||||
:on-zoom-in="zoomIn"
|
||||
:on-zoom-out="zoomOut"
|
||||
:on-zoom-reset="resetZoom"
|
||||
/>
|
||||
|
||||
<DesignerPreview
|
||||
:preview-url="previewUrl"
|
||||
:template-label="templateLabel"
|
||||
:production-pixels="productionPixelSize"
|
||||
:is-exporting="isExporting"
|
||||
:project-name="projectName"
|
||||
:is-saving="isSaving"
|
||||
:can-save="canSave"
|
||||
@export="handleExport"
|
||||
@download-preview="downloadPreview"
|
||||
@download-production="downloadProduction"
|
||||
@update:projectName="handleProjectNameUpdate"
|
||||
@save="handleSaveDesign"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="saveMessage"
|
||||
class="rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200"
|
||||
>
|
||||
{{ saveMessage }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="saveError"
|
||||
class="rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"
|
||||
>
|
||||
{{ saveError }}
|
||||
</p>
|
||||
|
||||
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/40">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Saved Projects
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Synced securely to Supabase</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-700 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-300 transition hover:border-sky-500/70 hover:text-sky-200 disabled:cursor-not-allowed disabled:border-slate-700/60 disabled:text-slate-500"
|
||||
:disabled="isLibraryLoading"
|
||||
@click="fetchDesigns"
|
||||
<NuxtLink
|
||||
to="/designer"
|
||||
class="group relative inline-flex overflow-hidden rounded-full bg-linear-to-r from-sky-500 to-blue-600 px-12 py-5 text-xl font-bold text-white shadow-2xl transition-all hover:scale-105 hover:shadow-sky-500/50 active:scale-95"
|
||||
>
|
||||
{{ isLibraryLoading ? "Refreshing…" : "Refresh" }}
|
||||
</button>
|
||||
</header>
|
||||
<span class="relative z-10 flex items-center gap-3">
|
||||
Start Designing
|
||||
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="absolute inset-0 bg-linear-to-r from-blue-600 to-sky-500 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="isLibraryLoading" class="mt-4 text-sm text-slate-400">
|
||||
Loading your library…
|
||||
<!-- Stats Section - Now on Left Side -->
|
||||
<div class="mx-auto grid max-w-4xl gap-8 pt-12 grid-cols-3 sm:grid-cols-3 lg:mx-0">
|
||||
<div class="space-y-2 text-center backdrop-blur-sm lg:text-left">
|
||||
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">12"</div>
|
||||
<div class="text-sm text-slate-400 drop-shadow-md">Standard Size</div>
|
||||
</div>
|
||||
<div v-else-if="!designs.length" class="mt-4 text-sm text-slate-500">
|
||||
Nothing saved yet—hit “Save to Library” once you love your design.
|
||||
<div class="space-y-2 text-center backdrop-blur-sm lg:text-left">
|
||||
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">300 DPI</div>
|
||||
<div class="text-sm text-slate-400 drop-shadow-md">Print Quality</div>
|
||||
</div>
|
||||
<ul v-else class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="design in designs"
|
||||
:key="design.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-slate-800/70 bg-slate-950/70 px-3 py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-100">{{ design.name }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
Updated {{ formatTimestamp(design.updatedAt) }}
|
||||
</p>
|
||||
<div class="space-y-2 text-center backdrop-blur-sm lg:text-left">
|
||||
<div class="text-3xl font-bold text-sky-400 drop-shadow-lg md:text-4xl">$39.99</div>
|
||||
<div class="text-sm text-slate-400 drop-shadow-md">Per Design</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="design.previewUrl"
|
||||
:href="design.previewUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-xs font-semibold uppercase tracking-wide text-sky-400 transition hover:text-sky-200"
|
||||
>
|
||||
Preview
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black p-6 shadow-2xl shadow-slate-950/60">
|
||||
<DesignerCanvas
|
||||
:size="displaySize"
|
||||
:background-color="selectedTemplate.backgroundColor"
|
||||
:register-canvas="registerCanvas"
|
||||
:unregister-canvas="unregisterCanvas"
|
||||
<!-- Right Side - Animated Slipmat -->
|
||||
<div class="relative hidden w-full lg:block lg:w-1/2">
|
||||
<div class="relative mx-auto max-w-4xl">
|
||||
<!-- Turntable Base -->
|
||||
<div class="absolute -inset-32 rounded-full bg-linear-to-br from-slate-800 to-slate-900 opacity-50 blur-3xl"></div>
|
||||
|
||||
<!-- Spinning Slipmat with SVG -->
|
||||
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[900px] sm:w-[900px]">
|
||||
<img
|
||||
src="/SLIPMATZ.svg"
|
||||
alt="Slipmatz Logo"
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-slate-400">
|
||||
Safe zone and bleed guides update automatically when you switch
|
||||
templates. Use the toolbar to layer text, shapes, and imagery inside the
|
||||
circular boundary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Wave -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-32 bg-linear-to-t from-slate-950 to-transparent"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
227
app/pages/orders.vue
Normal file
227
app/pages/orders.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
interface OrderRecord {
|
||||
id?: string;
|
||||
designId?: string;
|
||||
templateId?: string | null;
|
||||
amount?: string | number;
|
||||
currency?: string;
|
||||
customerEmail?: string | null;
|
||||
status?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
stripeSessionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const loginModal = useLoginModal();
|
||||
const { user, backendUser, initAuth, isLoading } = useAuth();
|
||||
|
||||
const customerEmail = computed(() => backendUser.value?.email || user.value?.email || null);
|
||||
|
||||
const orders = ref<OrderRecord[]>([]);
|
||||
const ordersLoading = ref(false);
|
||||
const ordersError = ref<string | null>(null);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value && customerEmail.value));
|
||||
|
||||
onMounted(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customerEmail.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
ordersLoading.value = true;
|
||||
ordersError.value = null;
|
||||
|
||||
try {
|
||||
const response = await $fetch<OrderRecord[]>("/api/orders", {
|
||||
query: { customerEmail: customerEmail.value ?? undefined },
|
||||
});
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
orders.value = response;
|
||||
} else if (response && typeof response === "object" && "orders" in response) {
|
||||
orders.value = (response as any).orders ?? [];
|
||||
} else {
|
||||
orders.value = [];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load order history", error);
|
||||
ordersError.value = error?.message ?? "Unable to load orders";
|
||||
orders.value = [];
|
||||
} finally {
|
||||
ordersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => customerEmail.value,
|
||||
(email) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
if (email) {
|
||||
fetchOrders();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const formatAmount = (record: OrderRecord) => {
|
||||
const rawAmount = record.amount;
|
||||
const amountString =
|
||||
typeof rawAmount === "string"
|
||||
? rawAmount
|
||||
: typeof rawAmount === "number"
|
||||
? rawAmount.toString()
|
||||
: "0";
|
||||
|
||||
const numericAmount = Number.parseFloat(amountString);
|
||||
const currencyCode = (record.currency ?? "USD").toUpperCase();
|
||||
|
||||
if (Number.isFinite(numericAmount)) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
}).format(numericAmount);
|
||||
}
|
||||
|
||||
return `${amountString} ${currencyCode}`.trim();
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const openLogin = () => {
|
||||
loginModal.value = true;
|
||||
router.push("/");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-4xl flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">Account</p>
|
||||
<h1 class="text-3xl font-semibold text-white">Order history</h1>
|
||||
<p class="text-sm text-slate-400">
|
||||
Review your completed purchases and keep track of the designs linked to each order.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="isLoading" class="grid gap-4">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h2 class="text-xl font-semibold text-white">Sign in to view orders</h2>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
Orders are associated with your Slipmatz account. Sign in to see the designs you've purchased.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500"
|
||||
@click="openLogin"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm text-slate-400">
|
||||
Signed in as <span class="font-medium text-slate-200">{{ customerEmail }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-700/70 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300 transition hover:border-slate-500 hover:text-white"
|
||||
@click="fetchOrders"
|
||||
:disabled="ordersLoading"
|
||||
>
|
||||
{{ ordersLoading ? 'Refreshing…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ordersError" class="rounded-2xl border border-rose-500/60 bg-rose-500/10 p-6 text-sm text-rose-100">
|
||||
{{ ordersError }}
|
||||
</div>
|
||||
|
||||
<div v-if="ordersLoading" class="grid gap-4">
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
<div class="h-24 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 text-sm text-slate-300">
|
||||
No orders yet. When you complete a purchase, your history will show up here for easy reference.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<article
|
||||
v-for="order in orders"
|
||||
:key="order.id || `${order.designId}-${order.createdAt}`"
|
||||
class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6 shadow-lg shadow-slate-950/40"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
{{ formatAmount(order) }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
{{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="order.status" class="rounded-full border border-slate-700/70 px-3 py-1 text-xs uppercase tracking-[0.25em] text-slate-300">
|
||||
{{ order.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div v-if="order.designId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Design ID</dt>
|
||||
<dd class="text-slate-200 break-all">{{ order.designId }}</dd>
|
||||
</div>
|
||||
<div v-if="order.templateId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Template</dt>
|
||||
<dd class="text-slate-200">{{ order.templateId }}</dd>
|
||||
</div>
|
||||
<div v-if="order.customerEmail">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Receipt sent to</dt>
|
||||
<dd class="text-slate-200">{{ order.customerEmail }}</dd>
|
||||
</div>
|
||||
<div v-if="order.stripeSessionId">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">Stripe session</dt>
|
||||
<dd class="text-slate-200 break-all">{{ order.stripeSessionId }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<NuxtLink
|
||||
v-if="order.designId"
|
||||
:to="{ path: '/', query: { designId: order.designId } }"
|
||||
class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-sky-400 transition hover:text-sky-300"
|
||||
>
|
||||
Reopen design
|
||||
<span aria-hidden="true">→</span>
|
||||
</NuxtLink>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
156
app/pages/profile.vue
Normal file
156
app/pages/profile.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const loginModal = useLoginModal();
|
||||
const { user, backendUser, initAuth, isLoading, signOut } = useAuth();
|
||||
|
||||
onMounted(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value));
|
||||
|
||||
const displayName = computed(() => {
|
||||
return (
|
||||
backendUser.value?.name ||
|
||||
user.value?.displayName ||
|
||||
backendUser.value?.email ||
|
||||
user.value?.email ||
|
||||
"Slipmat Creator"
|
||||
);
|
||||
});
|
||||
|
||||
const displayEmail = computed(() => backendUser.value?.email || user.value?.email || "Unknown");
|
||||
|
||||
const displayId = computed(() => backendUser.value?.id || user.value?.uid || null);
|
||||
|
||||
const lastLogin = computed(() => {
|
||||
const raw =
|
||||
backendUser.value?.lastLogin ||
|
||||
backendUser.value?.updatedAt ||
|
||||
user.value?.metadata?.lastSignInTime ||
|
||||
user.value?.metadata?.creationTime ||
|
||||
null;
|
||||
|
||||
return raw ? new Date(raw) : null;
|
||||
});
|
||||
|
||||
const profileFields = computed(() => {
|
||||
const entries: Array<{ label: string; value: string | null }> = [
|
||||
{ label: "Display name", value: displayName.value },
|
||||
{ label: "Email", value: displayEmail.value },
|
||||
];
|
||||
|
||||
if (displayId.value) {
|
||||
entries.push({ label: "User ID", value: displayId.value });
|
||||
}
|
||||
|
||||
if (lastLogin.value) {
|
||||
entries.push({ label: "Last login", value: lastLogin.value.toLocaleString() });
|
||||
}
|
||||
|
||||
if (backendUser.value?.role) {
|
||||
entries.push({ label: "Role", value: String(backendUser.value.role) });
|
||||
}
|
||||
|
||||
if (backendUser.value?.createdAt) {
|
||||
entries.push({ label: "Created", value: new Date(backendUser.value.createdAt).toLocaleString() });
|
||||
}
|
||||
|
||||
return entries;
|
||||
});
|
||||
|
||||
const openLogin = () => {
|
||||
loginModal.value = true;
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
console.error("Sign out failed", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-3xl flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">Account</p>
|
||||
<h1 class="text-3xl font-semibold text-white">Profile</h1>
|
||||
<p class="text-sm text-slate-400">
|
||||
View your Slipmatz account details and manage sessions. Changes to your profile are controlled through your authentication provider.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="isLoading" class="grid gap-4">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-slate-800 bg-slate-900/50" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h2 class="text-xl font-semibold text-white">You're signed out</h2>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
Sign in to view your profile information and order history.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500"
|
||||
@click="openLogin"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">{{ displayName }}</h2>
|
||||
<p class="text-sm text-slate-400">{{ displayEmail }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
class="rounded-md border border-slate-700/70 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-sky-500 hover:text-white"
|
||||
>
|
||||
View order history
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-rose-500/70 px-4 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/10"
|
||||
@click="handleSignOut"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h3 class="text-lg font-semibold text-white">Account details</h3>
|
||||
<dl class="mt-4 grid gap-4 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div v-for="field in profileFields" :key="field.label" class="space-y-1">
|
||||
<dt class="text-xs uppercase tracking-[0.25em] text-slate-500">{{ field.label }}</dt>
|
||||
<dd class="text-sm text-slate-200 break-all">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="backendUser" class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6">
|
||||
<h3 class="text-lg font-semibold text-white">Backend session</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
The following data is provided by the Slipmatz backend and may include additional metadata used for order fulfillment.
|
||||
</p>
|
||||
<pre class="mt-4 overflow-x-auto rounded-xl bg-slate-950/80 p-4 text-xs text-slate-300">
|
||||
{{ JSON.stringify(backendUser, null, 2) }}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
186
app/pages/register.vue
Normal file
186
app/pages/register.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const { user, registerWithEmail, signInWithGoogle, isLoading, error } = useAuth();
|
||||
const loginModal = useLoginModal();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const localError = ref<string | null>(null);
|
||||
|
||||
const isProcessing = computed(() => isSubmitting.value || isLoading.value);
|
||||
const combinedError = computed(() => localError.value || error.value || null);
|
||||
|
||||
const redirectIfAuthenticated = (maybeUser: unknown) => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
if (maybeUser) {
|
||||
router.replace("/");
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => user.value,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
redirectIfAuthenticated(currentUser);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
localError.value = null;
|
||||
|
||||
if (!email.value.trim()) {
|
||||
localError.value = "Email is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value.length < 6) {
|
||||
localError.value = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
localError.value = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await registerWithEmail(email.value.trim(), password.value);
|
||||
await router.replace("/");
|
||||
} catch (err: any) {
|
||||
localError.value = err?.message ?? "Registration failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleRegister = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
await router.replace("/");
|
||||
} catch (err: any) {
|
||||
localError.value = err?.message ?? "Google sign-in failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToSignIn = async () => {
|
||||
if (!process.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
loginModal.value = true;
|
||||
await router.push("/");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||
<AppNavbar />
|
||||
|
||||
<section class="mx-auto flex max-w-md flex-col gap-8 px-4 pt-16">
|
||||
<header class="space-y-3 text-center">
|
||||
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">Create Account</p>
|
||||
<h1 class="text-3xl font-semibold text-white">Join Slipmatz</h1>
|
||||
<p class="text-sm text-slate-400">
|
||||
Sign up with email and password to save your designs and return to them anytime.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="space-y-5 rounded-2xl border border-slate-800/60 bg-slate-900/80 p-6" @submit.prevent="handleRegister">
|
||||
<div class="space-y-1">
|
||||
<label for="email" class="block text-sm font-medium text-slate-300">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="password" class="block text-sm font-medium text-slate-300">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">Minimum 6 characters.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="confirm-password" class="block text-sm font-medium text-slate-300">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-white focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isProcessing"
|
||||
class="w-full rounded-md bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 transition hover:bg-emerald-400 disabled:opacity-60"
|
||||
>
|
||||
{{ isProcessing ? "Creating account..." : "Create account" }}
|
||||
</button>
|
||||
|
||||
<div class="my-2 flex items-center gap-3 text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
<span>or</span>
|
||||
<div class="flex-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isProcessing"
|
||||
class="w-full rounded-md border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-800 disabled:opacity-60"
|
||||
@click="handleGoogleRegister"
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p v-if="combinedError" class="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{{ combinedError }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400">
|
||||
Already have an account?
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-sky-400 hover:text-sky-300"
|
||||
@click.prevent="goToSignIn"
|
||||
>
|
||||
Sign in instead
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,153 +0,0 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useAuth0 } from "@auth0/auth0-vue";
|
||||
import { useNuxtApp, useRuntimeConfig } from "nuxt/app";
|
||||
import type { $Fetch } from "ofetch";
|
||||
|
||||
import type { ExportedDesign } from "./useSlipmatDesigner";
|
||||
|
||||
type DesignRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_url: string | null;
|
||||
preview_path?: string | null;
|
||||
design_json?: unknown;
|
||||
notes?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export interface SavedDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
templateId: string;
|
||||
previewUrl: string | null;
|
||||
notes?: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
designJson?: unknown;
|
||||
}
|
||||
|
||||
type SaveOptions = {
|
||||
id?: string;
|
||||
name: string;
|
||||
design: ExportedDesign;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const mapDesignRecord = (record: DesignRecord): SavedDesign => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
templateId: record.template_id,
|
||||
previewUrl: record.preview_url ?? null,
|
||||
notes: record.notes ?? undefined,
|
||||
createdAt: record.created_at ?? null,
|
||||
updatedAt: record.updated_at ?? null,
|
||||
designJson: record.design_json,
|
||||
});
|
||||
|
||||
export const useDesignPersistence = () => {
|
||||
const runtime = useRuntimeConfig();
|
||||
const audience = runtime.public.auth0?.audience;
|
||||
|
||||
const auth0 = process.client ? useAuth0() : null;
|
||||
const nuxtApp = useNuxtApp();
|
||||
const fetcher = nuxtApp.$fetch as $Fetch;
|
||||
|
||||
const designs = ref<SavedDesign[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const lastError = ref<string | null>(null);
|
||||
|
||||
const isAuthenticated = computed(() => auth0?.isAuthenticated.value ?? false);
|
||||
|
||||
const acquireToken = async (): Promise<string> => {
|
||||
if (!auth0) {
|
||||
throw new Error("Auth0 client is not available.");
|
||||
}
|
||||
|
||||
return auth0.getAccessTokenSilently({
|
||||
authorizationParams: audience
|
||||
? {
|
||||
audience,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDesigns = async () => {
|
||||
if (!auth0 || !isAuthenticated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
lastError.value = null;
|
||||
|
||||
try {
|
||||
const token = await acquireToken();
|
||||
const response = await fetcher<DesignRecord[]>("/api/designs", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
designs.value = Array.isArray(response)
|
||||
? response.map(mapDesignRecord)
|
||||
: [];
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveDesign = async ({ id, name, design, notes }: SaveOptions) => {
|
||||
if (!auth0 || !isAuthenticated.value) {
|
||||
throw new Error("User must be authenticated before saving a design.");
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
lastError.value = null;
|
||||
|
||||
try {
|
||||
const token = await acquireToken();
|
||||
const payload = {
|
||||
id,
|
||||
name,
|
||||
templateId: design.templateId,
|
||||
previewDataUrl: design.previewUrl,
|
||||
productionDataUrl: design.productionUrl,
|
||||
designJson: design.canvasJson,
|
||||
notes,
|
||||
};
|
||||
|
||||
const response = await fetcher<DesignRecord>("/api/designs", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = mapDesignRecord(response);
|
||||
const next = designs.value.filter((item) => item.id !== saved.id);
|
||||
designs.value = [saved, ...next];
|
||||
return saved;
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
designs,
|
||||
isLoading,
|
||||
isSaving,
|
||||
lastError,
|
||||
isAuthenticated,
|
||||
fetchDesigns,
|
||||
saveDesign,
|
||||
};
|
||||
};
|
||||
@@ -13,16 +13,6 @@ export interface SlipmatTemplate {
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface ExportedDesign {
|
||||
previewUrl: string;
|
||||
previewBlob: Blob;
|
||||
productionUrl: string;
|
||||
productionBlob: Blob;
|
||||
templateId: string;
|
||||
createdAt: string;
|
||||
canvasJson: FabricCanvasJSON;
|
||||
}
|
||||
|
||||
const DISPLAY_SIZE = 720;
|
||||
const PREVIEW_SIZE = 1024;
|
||||
const MIN_ZOOM = 0.5;
|
||||
@@ -60,17 +50,27 @@ const TEMPLATE_PRESETS: SlipmatTemplate[] = [
|
||||
];
|
||||
|
||||
type FabricCanvas = FabricNamespace.Canvas;
|
||||
type FabricSerializedCanvas = ReturnType<FabricCanvas["toJSON"]>;
|
||||
type FabricCircle = FabricNamespace.Circle;
|
||||
type FabricRect = FabricNamespace.Rect;
|
||||
type FabricTextbox = FabricNamespace.Textbox;
|
||||
type FabricObject = FabricNamespace.Object;
|
||||
type FabricCanvasJSON = ReturnType<FabricCanvas["toJSON"]>;
|
||||
|
||||
type CanvasReadyPayload = {
|
||||
canvas: FabricCanvas;
|
||||
fabric: FabricModule;
|
||||
};
|
||||
|
||||
export interface ExportedDesign {
|
||||
previewUrl: string;
|
||||
previewBlob: Blob;
|
||||
productionUrl: string;
|
||||
productionBlob: Blob;
|
||||
templateId: string;
|
||||
createdAt: string;
|
||||
canvasJson: FabricSerializedCanvas;
|
||||
}
|
||||
|
||||
const FALLBACK_TEMPLATE: SlipmatTemplate = TEMPLATE_PRESETS[0] ?? {
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
@@ -322,8 +322,6 @@ export const useSlipmatDesigner = () => {
|
||||
instance.on(eventName, handleMutation);
|
||||
});
|
||||
|
||||
instance.on("after:render", () => schedulePreviewRefresh());
|
||||
|
||||
const selectionEvents = [
|
||||
"selection:created",
|
||||
"selection:updated",
|
||||
@@ -607,6 +605,77 @@ export const useSlipmatDesigner = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const waitForCanvasReady = async (): Promise<FabricCanvas> => {
|
||||
if (canvas.value) {
|
||||
return canvas.value;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(
|
||||
canvas,
|
||||
(value) => {
|
||||
if (value) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
});
|
||||
|
||||
if (!canvas.value) {
|
||||
throw new Error("Canvas not ready");
|
||||
}
|
||||
|
||||
return canvas.value;
|
||||
};
|
||||
|
||||
const loadDesign = async (payload: {
|
||||
templateId?: string | null;
|
||||
canvasJson: string | FabricSerializedCanvas;
|
||||
previewUrl?: string | null;
|
||||
}) => {
|
||||
if (payload.templateId) {
|
||||
selectTemplate(payload.templateId);
|
||||
}
|
||||
|
||||
const currentCanvas = await waitForCanvasReady();
|
||||
if (!fabricApi.value) {
|
||||
throw new Error("Fabric API not ready");
|
||||
}
|
||||
|
||||
const parsedJson =
|
||||
typeof payload.canvasJson === "string"
|
||||
? (JSON.parse(payload.canvasJson) as FabricSerializedCanvas)
|
||||
: payload.canvasJson;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentCanvas.loadFromJSON(parsedJson, () => {
|
||||
maintainStaticLayerOrder();
|
||||
updateSelectedStyleState();
|
||||
currentCanvas.renderAll();
|
||||
schedulePreviewRefresh();
|
||||
resolve();
|
||||
});
|
||||
}).catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Reset cached assets; caller can provide preview if available.
|
||||
previewBlob.value = null;
|
||||
productionBlob.value = null;
|
||||
if (productionObjectUrl.value) {
|
||||
URL.revokeObjectURL(productionObjectUrl.value);
|
||||
productionObjectUrl.value = null;
|
||||
}
|
||||
|
||||
if (payload.previewUrl) {
|
||||
previewUrl.value = payload.previewUrl;
|
||||
} else {
|
||||
await refreshPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const exportDesign = async (): Promise<ExportedDesign | null> => {
|
||||
const currentCanvas = canvas.value;
|
||||
if (!currentCanvas) {
|
||||
@@ -631,6 +700,8 @@ export const useSlipmatDesigner = () => {
|
||||
});
|
||||
const productionDataBlob = await dataUrlToBlob(productionDataUrl);
|
||||
|
||||
const canvasJson = currentCanvas.toJSON();
|
||||
|
||||
previewUrl.value = previewDataUrl;
|
||||
previewBlob.value = previewDataBlob;
|
||||
productionBlob.value = productionDataBlob;
|
||||
@@ -640,8 +711,6 @@ export const useSlipmatDesigner = () => {
|
||||
}
|
||||
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
||||
|
||||
const canvasJson = currentCanvas.toJSON() as FabricCanvasJSON;
|
||||
|
||||
return {
|
||||
previewUrl: previewDataUrl,
|
||||
previewBlob: previewDataBlob,
|
||||
@@ -656,23 +725,6 @@ export const useSlipmatDesigner = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadDesignFromJson = async (designJson: FabricCanvasJSON) => {
|
||||
const fabric = fabricApi.value;
|
||||
const currentCanvas = canvas.value;
|
||||
if (!fabric || !currentCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
currentCanvas.loadFromJSON(designJson, () => {
|
||||
currentCanvas.renderAll();
|
||||
maintainStaticLayerOrder();
|
||||
schedulePreviewRefresh();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const downloadPreview = async () => {
|
||||
if (!previewUrl.value) {
|
||||
await refreshPreview();
|
||||
@@ -699,6 +751,20 @@ export const useSlipmatDesigner = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const setBackgroundColor = (color: string) => {
|
||||
const bgCircle = backgroundCircle.value;
|
||||
if (!bgCircle || !canvas.value) {
|
||||
return;
|
||||
}
|
||||
bgCircle.set({ fill: color });
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
backgroundColor: color,
|
||||
};
|
||||
canvas.value.requestRenderAll();
|
||||
schedulePreviewRefresh();
|
||||
};
|
||||
|
||||
watch(selectedTemplate, () => {
|
||||
resetZoom();
|
||||
applyTemplateToCanvas();
|
||||
@@ -711,6 +777,7 @@ export const useSlipmatDesigner = () => {
|
||||
templates,
|
||||
selectedTemplate,
|
||||
selectTemplate,
|
||||
loadDesign,
|
||||
displaySize,
|
||||
productionPixelSize,
|
||||
templateLabel,
|
||||
@@ -733,13 +800,13 @@ export const useSlipmatDesigner = () => {
|
||||
addImageFromFile,
|
||||
setActiveFillColor,
|
||||
setActiveStrokeColor,
|
||||
setBackgroundColor,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
clearDesign,
|
||||
exportDesign,
|
||||
loadDesignFromJson,
|
||||
downloadPreview,
|
||||
downloadProduction,
|
||||
refreshPreview,
|
||||
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
slipmatz-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: slipmatz-web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Add your environment variables here
|
||||
- NUXT_PUBLIC_FIREBASE_API_KEY=${NUXT_PUBLIC_FIREBASE_API_KEY}
|
||||
- NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN}
|
||||
- NUXT_PUBLIC_FIREBASE_PROJECT_ID=${NUXT_PUBLIC_FIREBASE_PROJECT_ID}
|
||||
- NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET}
|
||||
- NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID}
|
||||
- NUXT_PUBLIC_FIREBASE_APP_ID=${NUXT_PUBLIC_FIREBASE_APP_ID}
|
||||
- NUXT_PUBLIC_STORAGE_URL=${NUXT_PUBLIC_STORAGE_URL}
|
||||
- NUXT_PUBLIC_BACKEND_URL=${NUXT_PUBLIC_BACKEND_URL}
|
||||
- NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.slipmatz-web.rule=Host(`${DOMAIN:-slipmatz.com}`)"
|
||||
- "traefik.http.routers.slipmatz-web.entrypoints=websecure"
|
||||
- "traefik.http.routers.slipmatz-web.tls=true"
|
||||
- "traefik.http.routers.slipmatz-web.tls.certresolver=le"
|
||||
- "traefik.http.services.slipmatz-web.loadbalancer.server.port=3000"
|
||||
# HTTP to HTTPS redirect
|
||||
- "traefik.http.routers.slipmatz-web-http.rule=Host(`${DOMAIN:-slipmatz.com}`)"
|
||||
- "traefik.http.routers.slipmatz-web-http.entrypoints=web"
|
||||
- "traefik.http.routers.slipmatz-web-http.middlewares=https-redirect"
|
||||
- "traefik.http.middlewares.https-redirect.redirectscheme.scheme=https"
|
||||
networks:
|
||||
- traefik-public
|
||||
- crew-app-net
|
||||
- slipmatz-network
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
crew-app-net:
|
||||
external: true
|
||||
slipmatz-network:
|
||||
driver: bridge
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useAuth0 } from "@auth0/auth0-vue";
|
||||
import { watch } from "vue";
|
||||
import { abortNavigation, defineNuxtRouteMiddleware } from "nuxt/app";
|
||||
|
||||
const waitUntilLoaded = async (isLoading: { value: boolean }) => {
|
||||
if (!isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(
|
||||
() => isLoading.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (process.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (to.path.startsWith("/auth/callback")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth0 = useAuth0();
|
||||
await waitUntilLoaded(auth0.isLoading);
|
||||
|
||||
if (auth0.isAuthenticated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await auth0.loginWithRedirect({
|
||||
appState: { target: to.fullPath },
|
||||
});
|
||||
|
||||
return abortNavigation();
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useAuth0 } from "@auth0/auth0-vue";
|
||||
import { watch } from "vue";
|
||||
import { abortNavigation } from "#app";
|
||||
|
||||
const waitUntilLoaded = async (isLoading: { value: boolean }) => {
|
||||
if (!isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(
|
||||
() => isLoading.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (import.meta.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (to.path.startsWith("/auth/callback")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth0 = useAuth0();
|
||||
await waitUntilLoaded(auth0.isLoading);
|
||||
|
||||
if (auth0.isAuthenticated.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await auth0.loginWithRedirect({
|
||||
appState: { target: to.fullPath },
|
||||
});
|
||||
|
||||
return abortNavigation();
|
||||
});
|
||||
@@ -5,31 +5,29 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
devtools: { enabled: true },
|
||||
css: ["./app/assets/css/main.css"],
|
||||
runtimeConfig: {
|
||||
auth0: {
|
||||
clientSecret: process.env.AUTH0_CLIENT_SECRET,
|
||||
},
|
||||
supabase: {
|
||||
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
},
|
||||
public: {
|
||||
auth0: {
|
||||
domain: process.env.AUTH0_DOMAIN,
|
||||
clientId: process.env.AUTH0_CLIENT_ID,
|
||||
audience: process.env.AUTH0_AUDIENCE,
|
||||
scope: process.env.AUTH0_SCOPE ?? "openid profile email",
|
||||
baseUrl: process.env.AUTH0_BASE_URL ?? "http://localhost:3000",
|
||||
redirectUri:
|
||||
process.env.AUTH0_REDIRECT_URI ?? "http://localhost:3000/auth/callback",
|
||||
},
|
||||
supabase: {
|
||||
url: process.env.SUPABASE_URL,
|
||||
anonKey: process.env.SUPABASE_ANON_KEY,
|
||||
storageBucket: process.env.SUPABASE_STORAGE_BUCKET ?? "designs",
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: ["@nuxtjs/color-mode"],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
runtimeConfig: {
|
||||
stripeSecretKey: process.env.NUXT_STRIPE_SECRET_KEY,
|
||||
stripeWebhookSecret: process.env.NUXT_STRIPE_WEBHOOK_SECRET,
|
||||
public: {
|
||||
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
|
||||
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
|
||||
firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
|
||||
firebaseMeasurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
|
||||
stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
backendUrl: process.env.NUXT_PUBLIC_BACKEND_URL || 'http://localhost:3000',
|
||||
storageUrl: process.env.NUXT_PUBLIC_STORAGE_URL || 'http://localhost:9000',
|
||||
}
|
||||
},
|
||||
colorMode: {
|
||||
preference: 'light',
|
||||
fallback: 'light',
|
||||
classSuffix: ''
|
||||
}
|
||||
});
|
||||
|
||||
4069
package-lock.json
generated
4069
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -10,18 +10,14 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-vue": "^2.4.0",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@supabase/supabase-js": "^2.48.0",
|
||||
"fabric": "^6.0.2",
|
||||
"jose": "^5.9.6",
|
||||
"firebase": "^12.5.0",
|
||||
"nuxt": "^4.2.0",
|
||||
"zod": "^3.23.8",
|
||||
"stripe": "^19.3.0",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createAuth0 } from "@auth0/auth0-vue";
|
||||
import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
|
||||
|
||||
declare module "@auth0/auth0-vue" {
|
||||
interface AppState {
|
||||
target?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const config = useRuntimeConfig();
|
||||
const auth0Config = config.public.auth0;
|
||||
|
||||
if (!auth0Config?.domain || !auth0Config?.clientId) {
|
||||
if (process.dev) {
|
||||
console.warn(
|
||||
"Auth0 configuration is incomplete. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID in your environment."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = auth0Config.redirectUri ?? `${auth0Config.baseUrl}/auth/callback`;
|
||||
|
||||
nuxtApp.vueApp.use(
|
||||
createAuth0({
|
||||
domain: auth0Config.domain,
|
||||
clientId: auth0Config.clientId,
|
||||
authorizationParams: {
|
||||
redirect_uri: redirectUri,
|
||||
audience: auth0Config.audience || undefined,
|
||||
scope: auth0Config.scope,
|
||||
},
|
||||
cacheLocation: "localstorage",
|
||||
useRefreshTokens: true,
|
||||
onRedirectCallback: (appState) => {
|
||||
const targetPath = appState?.target ?? "/";
|
||||
if (nuxtApp.$router) {
|
||||
nuxtApp.$router.replace(targetPath).catch(() => {
|
||||
window.location.assign(targetPath);
|
||||
});
|
||||
} else {
|
||||
window.location.assign(targetPath);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const { url, anonKey } = useRuntimeConfig().public.supabase;
|
||||
|
||||
if (!url || !anonKey) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn(
|
||||
"Supabase configuration is incomplete. Set SUPABASE_URL and SUPABASE_ANON_KEY in your environment."
|
||||
);
|
||||
}
|
||||
|
||||
nuxtApp.provide("supabase", null);
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createClient(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
storageKey: "slipmatz.supabase.auth",
|
||||
},
|
||||
});
|
||||
|
||||
nuxtApp.provide("supabase", supabase);
|
||||
nuxtApp.vueApp.config.globalProperties.$supabase = supabase;
|
||||
});
|
||||
1765
public/SLIPMATZ.svg
Normal file
1765
public/SLIPMATZ.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 187 KiB |
74
server/api/checkout.session.post.ts
Normal file
74
server/api/checkout.session.post.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const stripeSecretKey = config.stripeSecretKey;
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Stripe secret key not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2025-10-29.clover",
|
||||
});
|
||||
|
||||
const body = await readBody<{
|
||||
designId: string;
|
||||
templateId?: string;
|
||||
designName?: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
customerEmail?: string;
|
||||
}>(event);
|
||||
|
||||
if (
|
||||
!body?.designId ||
|
||||
!body?.amount ||
|
||||
!body?.successUrl ||
|
||||
!body?.cancelUrl
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
const { currency = "usd" } = body;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
billing_address_collection: "auto",
|
||||
customer_email: body.customerEmail,
|
||||
shipping_address_collection: { allowed_countries: ["US", "CA"] },
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency,
|
||||
unit_amount: Math.round(body.amount * 100),
|
||||
product_data: {
|
||||
name: body.designName ?? `Slipmat design ${body.designId}`,
|
||||
metadata: {
|
||||
designId: body.designId,
|
||||
...(body.templateId ? { templateId: body.templateId } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
designId: body.designId,
|
||||
...(body.templateId ? { templateId: body.templateId } : {}),
|
||||
},
|
||||
success_url: body.successUrl,
|
||||
cancel_url: body.cancelUrl,
|
||||
});
|
||||
|
||||
return { id: session.id, url: session.url };
|
||||
});
|
||||
62
server/api/checkout/[sessionId].get.ts
Normal file
62
server/api/checkout/[sessionId].get.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const stripeSecretKey = config.stripeSecretKey
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Stripe secret key not configured' })
|
||||
}
|
||||
|
||||
const params = event.context.params as { sessionId?: string }
|
||||
const sessionId = params?.sessionId
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing session id' })
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: '2025-10-29.clover',
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['payment_intent', 'customer'],
|
||||
})
|
||||
|
||||
const customerDetails = session.customer_details ?? null
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
paymentStatus: session.payment_status,
|
||||
amountTotal: session.amount_total,
|
||||
currency: session.currency,
|
||||
customerEmail: customerDetails?.email ?? session.customer_email ?? null,
|
||||
customerName: customerDetails?.name ?? null,
|
||||
createdAt: session.created ? new Date(session.created * 1000).toISOString() : null,
|
||||
metadata: session.metadata ?? {},
|
||||
customerDetails: customerDetails
|
||||
? {
|
||||
name: customerDetails.name ?? null,
|
||||
email: customerDetails.email ?? null,
|
||||
phone: customerDetails.phone ?? null,
|
||||
address: customerDetails.address
|
||||
? {
|
||||
line1: customerDetails.address.line1 ?? null,
|
||||
line2: customerDetails.address.line2 ?? null,
|
||||
city: customerDetails.address.city ?? null,
|
||||
state: customerDetails.address.state ?? null,
|
||||
postalCode: customerDetails.address.postal_code ?? null,
|
||||
country: customerDetails.address.country ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode ?? 500,
|
||||
statusMessage: error?.message ?? 'Unable to retrieve checkout session',
|
||||
})
|
||||
}
|
||||
})
|
||||
63
server/api/designs.post.ts
Normal file
63
server/api/designs.post.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{
|
||||
designId: string;
|
||||
templateId: string;
|
||||
ownerEmail?: string | null;
|
||||
ownerId?: string | null;
|
||||
previewUrl?: string | null;
|
||||
productionUrl?: string | null;
|
||||
canvasJson: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>(event);
|
||||
|
||||
if (!body?.designId || !body?.templateId || body.canvasJson === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing required design fields",
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const backendUrl = config.public?.backendUrl;
|
||||
|
||||
if (!backendUrl) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Backend URL not configured",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the authorization token from the incoming request
|
||||
const authHeader = getHeader(event, "authorization");
|
||||
|
||||
const payload = {
|
||||
designId: body.designId,
|
||||
templateId: body.templateId,
|
||||
ownerEmail: body.ownerEmail ?? null,
|
||||
ownerId: body.ownerId ?? null,
|
||||
previewUrl: body.previewUrl ?? null,
|
||||
productionUrl: body.productionUrl ?? null,
|
||||
canvasJson: body.canvasJson,
|
||||
metadata: body.metadata ?? {},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await $fetch("/designs", {
|
||||
baseURL: backendUrl,
|
||||
method: "POST",
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[designs] Failed to forward design payload", err);
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: (err as Error)?.message ?? "Failed to persist design",
|
||||
});
|
||||
}
|
||||
});
|
||||
30
server/api/designs/[designId].get.ts
Normal file
30
server/api/designs/[designId].get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = event.context.params as { designId?: string }
|
||||
const designId = params?.designId
|
||||
|
||||
if (!designId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing design id" })
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const backendUrl = config.public?.backendUrl
|
||||
|
||||
if (!backendUrl) {
|
||||
throw createError({ statusCode: 500, statusMessage: "Backend URL not configured" })
|
||||
}
|
||||
|
||||
try {
|
||||
const design = await $fetch(`/designs/${encodeURIComponent(designId)}`, {
|
||||
baseURL: backendUrl,
|
||||
method: "GET",
|
||||
})
|
||||
|
||||
return design
|
||||
} catch (err) {
|
||||
console.error(`[designs] Failed to fetch design ${designId}`, err)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: (err as Error)?.message ?? "Failed to load design",
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createError } from "h3";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
|
||||
type DesignRecord = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_url: string | null;
|
||||
preview_path: string | null;
|
||||
design_json: unknown;
|
||||
notes?: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const id = event.context.params?.id;
|
||||
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Design id is required." });
|
||||
}
|
||||
|
||||
const response = await supabase
|
||||
.from("designs")
|
||||
.select("id, user_id, name, template_id, preview_url, preview_path, design_json, notes, created_at, updated_at")
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.sub)
|
||||
.maybeSingle();
|
||||
|
||||
if (response.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to load design: ${response.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Design not found." });
|
||||
}
|
||||
|
||||
return response.data as DesignRecord;
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createError, getQuery } from "h3";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
|
||||
type DesignSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_url: string | null;
|
||||
preview_path: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const query = getQuery(event);
|
||||
|
||||
const limit = query.limit ? Number.parseInt(String(query.limit), 10) : 20;
|
||||
const sanitizedLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20;
|
||||
|
||||
const response = await supabase
|
||||
.from("designs")
|
||||
.select("id, name, template_id, preview_url, preview_path, created_at, updated_at")
|
||||
.eq("user_id", user.sub)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(sanitizedLimit);
|
||||
|
||||
if (response.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to fetch designs: ${response.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (response.data as DesignSummary[]) ?? [];
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createError, readBody } from "h3";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1).max(120),
|
||||
templateId: z.string().min(1).max(64),
|
||||
previewDataUrl: z.string().regex(/^data:image\/png;base64,/),
|
||||
productionDataUrl: z.string().regex(/^data:image\/png;base64,/).optional(),
|
||||
designJson: z.record(z.any()),
|
||||
notes: z.string().max(640).optional(),
|
||||
});
|
||||
|
||||
type RequestPayload = z.infer<typeof requestSchema>;
|
||||
|
||||
type DesignRecord = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_path: string;
|
||||
preview_url: string | null;
|
||||
design_json: unknown;
|
||||
notes?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
const dataUrlToBuffer = (dataUrl: string): Buffer => {
|
||||
const [, base64Data] = dataUrl.split(",");
|
||||
if (!base64Data) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid image data URI." });
|
||||
}
|
||||
return Buffer.from(base64Data, "base64");
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const rawBody = await readBody<RequestPayload>(event);
|
||||
const parsed = requestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid request body.",
|
||||
data: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
const config = useRuntimeConfig();
|
||||
const bucket = config.public.supabase?.storageBucket;
|
||||
|
||||
if (!bucket) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Supabase storage bucket is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const designId = body.id ?? randomUUID();
|
||||
const filePath = `previews/${user.sub}/${designId}.png`;
|
||||
|
||||
const uploadResult = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(filePath, dataUrlToBuffer(body.previewDataUrl), {
|
||||
contentType: "image/png",
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadResult.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to upload preview: ${uploadResult.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: publicUrlData } = supabase.storage.from(bucket).getPublicUrl(filePath);
|
||||
const previewUrl = publicUrlData.publicUrl ?? null;
|
||||
|
||||
const record: DesignRecord = {
|
||||
id: designId,
|
||||
user_id: user.sub,
|
||||
name: body.name,
|
||||
template_id: body.templateId,
|
||||
preview_path: filePath,
|
||||
preview_url: previewUrl,
|
||||
design_json: body.designJson,
|
||||
notes: body.notes ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const upsertResult = await supabase
|
||||
.from("designs")
|
||||
.upsert(record, { onConflict: "id" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertResult.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to save design: ${upsertResult.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertResult.data;
|
||||
});
|
||||
43
server/api/orders.get.ts
Normal file
43
server/api/orders.get.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const backendUrl = config.public?.backendUrl;
|
||||
|
||||
if (!backendUrl) {
|
||||
throw createError({ statusCode: 500, statusMessage: "Backend URL not configured" });
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const customerEmail = typeof query.customerEmail === "string" ? query.customerEmail : null;
|
||||
|
||||
if (!customerEmail) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing customerEmail" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch("/transactions", {
|
||||
baseURL: backendUrl,
|
||||
method: "GET",
|
||||
query: { customerEmail },
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result && Array.isArray((result as any).data)) {
|
||||
return (result as any).data;
|
||||
}
|
||||
|
||||
if (result && Array.isArray((result as any).orders)) {
|
||||
return (result as any).orders;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("[orders] Failed to fetch order history", err);
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: (err as Error)?.message ?? "Failed to load order history",
|
||||
});
|
||||
}
|
||||
});
|
||||
73
server/api/transactions.post.ts
Normal file
73
server/api/transactions.post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{
|
||||
stripeSessionId: string;
|
||||
designId: string;
|
||||
templateId?: string;
|
||||
amount: number | string;
|
||||
currency: string;
|
||||
customerEmail?: string;
|
||||
customerDetails?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: {
|
||||
line1?: string | null;
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
} | null;
|
||||
};
|
||||
}>(event);
|
||||
|
||||
if (!body?.stripeSessionId || !body?.designId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const backendUrl = config.public?.backendUrl;
|
||||
|
||||
if (!backendUrl) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Backend URL not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const record = {
|
||||
stripeSessionId: body.stripeSessionId,
|
||||
designId: body.designId,
|
||||
templateId: body.templateId ?? null,
|
||||
amount: body.amount.toString(),
|
||||
currency: body.currency,
|
||||
customerEmail: body.customerEmail ?? null,
|
||||
customerDetails: body.customerDetails ?? null,
|
||||
};
|
||||
|
||||
console.log("[transactions] Forwarding record to backend:", record);
|
||||
|
||||
try {
|
||||
const backendResult = await $fetch("/transactions", {
|
||||
baseURL: backendUrl,
|
||||
method: "POST",
|
||||
body: record,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
receivedAt: new Date().toISOString(),
|
||||
backendResult,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[transactions] Failed to forward to backend", err);
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage:
|
||||
(err as Error)?.message ?? "Failed to save transaction to backend",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createError, getHeader, type H3Event } from "h3";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
jwtVerify,
|
||||
type JWTPayload,
|
||||
type JWTVerifyOptions,
|
||||
} from "jose";
|
||||
|
||||
const globalKey = Symbol.for("slipmatz.auth0.jwks");
|
||||
|
||||
type GlobalWithJwks = typeof globalThis & {
|
||||
[globalKey]?: ReturnType<typeof createRemoteJWKSet>;
|
||||
};
|
||||
|
||||
const getJwks = (issuer: string) => {
|
||||
const globalScope = globalThis as GlobalWithJwks;
|
||||
if (!globalScope[globalKey]) {
|
||||
globalScope[globalKey] = createRemoteJWKSet(
|
||||
new URL(`${issuer}.well-known/jwks.json`)
|
||||
);
|
||||
}
|
||||
return globalScope[globalKey]!;
|
||||
};
|
||||
|
||||
export type Auth0TokenPayload = JWTPayload & {
|
||||
sub: string;
|
||||
scope?: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
export const verifyAccessToken = async (token: string): Promise<Auth0TokenPayload> => {
|
||||
const config = useRuntimeConfig();
|
||||
const domain = config.public.auth0?.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Auth0 domain is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const issuer = `https://${domain}/`;
|
||||
const jwks = getJwks(issuer);
|
||||
|
||||
const options: JWTVerifyOptions = {
|
||||
issuer,
|
||||
};
|
||||
|
||||
if (config.public.auth0?.audience) {
|
||||
options.audience = config.public.auth0.audience;
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(token, jwks, options);
|
||||
if (!payload.sub) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid access token payload.",
|
||||
});
|
||||
}
|
||||
|
||||
return payload as Auth0TokenPayload;
|
||||
};
|
||||
|
||||
export const requireAuth0User = async (event: H3Event): Promise<Auth0TokenPayload> => {
|
||||
const header = getHeader(event, "authorization");
|
||||
if (!header) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Authorization header missing." });
|
||||
}
|
||||
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid authorization format." });
|
||||
}
|
||||
|
||||
const token = match[1];
|
||||
try {
|
||||
return await verifyAccessToken(token);
|
||||
} catch (error) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Access token verification failed." });
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createError } from "h3";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
const globalKey = Symbol.for("slipmatz.supabase.serviceClient");
|
||||
|
||||
type GlobalWithSupabase = typeof globalThis & {
|
||||
[globalKey]?: SupabaseClient;
|
||||
};
|
||||
|
||||
export const getSupabaseServiceClient = (): SupabaseClient => {
|
||||
const config = useRuntimeConfig();
|
||||
const supabaseUrl = config.public.supabase?.url;
|
||||
const serviceRoleKey = config.supabase?.serviceRoleKey;
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Supabase environment variables are not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const scope = globalThis as GlobalWithSupabase;
|
||||
if (!scope[globalKey]) {
|
||||
scope[globalKey] = createClient(supabaseUrl, serviceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return scope[globalKey]!;
|
||||
};
|
||||
37
test-workflow.sh
Executable file
37
test-workflow.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Test GitHub Actions workflow locally using act
|
||||
|
||||
echo "🧪 Testing GitHub Actions workflow locally..."
|
||||
echo ""
|
||||
|
||||
# Check if act is installed
|
||||
if ! command -v act &> /dev/null; then
|
||||
echo "❌ act is not installed. Install with: brew install act"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Change to slipmatz-web directory
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
echo "📋 Available workflows:"
|
||||
act --list
|
||||
echo ""
|
||||
|
||||
echo "🔍 Dry-run (no execution):"
|
||||
echo " act pull_request --container-architecture linux/amd64 --dryrun"
|
||||
echo ""
|
||||
|
||||
echo "🏗️ Build only (doesn't push to registry):"
|
||||
echo " act pull_request --container-architecture linux/amd64 -j build-and-push"
|
||||
echo ""
|
||||
|
||||
echo "🚀 Test push event (simulates main branch push):"
|
||||
echo " act push --container-architecture linux/amd64 -j build-and-push"
|
||||
echo ""
|
||||
|
||||
# Ask user what to do
|
||||
read -p "Run dry-run test? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
act pull_request --container-architecture linux/amd64 --dryrun
|
||||
fi
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user