This commit is contained in:
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
|
||||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 tablejerseys-web:dev .
|
||||||
|
docker save tablejerseys-web:dev | gzip > tablejerseys-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/tablejerseys-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/tablejerseys-web_dev"
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
echo "📦 Loading image"
|
||||||
|
docker load < /tmp/tablejerseys-web_dev.tar.gz
|
||||||
|
|
||||||
|
echo "🧹 Removing old tablejerseys-web images"
|
||||||
|
docker images | grep tablejerseys-web | grep -v "$(docker images tablejerseys-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=tablejerseys-web-dev
|
||||||
|
docker compose down || true
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "🧪 Testing container health"
|
||||||
|
sleep 15
|
||||||
|
if ! docker ps | grep -q tablejerseys-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/tablejerseys-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 tablejerseys-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 tablejerseys-web:latest .
|
||||||
|
docker save tablejerseys-web:latest | gzip > tablejerseys-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 tablejerseys-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/tablejerseys-web"
|
||||||
|
mkdir -p $DEPLOY_DIR
|
||||||
|
cd /tmp
|
||||||
|
docker load < tablejerseys-web.tar.gz
|
||||||
|
|
||||||
|
echo "Removing old tablejerseys-web images"
|
||||||
|
CURRENT_IMAGE=$(docker images tablejerseys-web:latest -q)
|
||||||
|
docker images | grep tablejerseys-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=tablejerseys.com
|
||||||
|
docker compose up -d
|
||||||
|
sleep 10
|
||||||
|
rm -f /tmp/tablejerseys-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://tablejerseys.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://tablejerseys.com || exit 1
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
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
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Slipmatz Designer
|
||||||
|
|
||||||
|
Nuxt 4 single-page experience for creating custom slipmat artwork. Users pick a vinyl template, layer text/images/shapes on a circular Fabric.js canvas, generate a polished preview, and export both a web-friendly and production-ready PNG (e.g. 12" @ 300 DPI ⇒ 3600×3600 px).
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- One-click export that produces both preview and print PNGs and offers direct downloads
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit [http://localhost:3000](http://localhost:3000) to start designing.
|
||||||
|
|
||||||
|
## 🛠️ Production Builds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # compile client + server bundles
|
||||||
|
npm run preview # optional: preview the built output
|
||||||
|
```
|
||||||
|
|
||||||
|
Nuxt outputs the server bundle into `.output/`. You can serve it with `node .output/server/index.mjs` or deploy through your preferred Node hosting provider.
|
||||||
|
|
||||||
|
## 🧩 Export Workflow
|
||||||
|
|
||||||
|
1. Choose a slipmat template to match the order (bleed/safe-zone update automatically).
|
||||||
|
2. Compose the design with the toolbar tools—the canvas enforces a circular clip.
|
||||||
|
3. Click **Generate Files** to render both preview and print PNGs.
|
||||||
|
4. Use the download buttons to retrieve the assets:
|
||||||
|
- _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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## 🧭 Next Ideas
|
||||||
|
|
||||||
|
- Persist projects (auth + cloud storage)
|
||||||
|
- CMYK color profile previews & bleed handling
|
||||||
|
- 3D platter preview using Three.js
|
||||||
|
- Admin dashboard for incoming print jobs
|
||||||
|
|
||||||
|
Contributions & ideas are welcome—have fun crafting vinyl eye candy! 🎛️
|
||||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
14
app/assets/css/main.css
Normal file
14
app/assets/css/main.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--animate-spin-slow: spin 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
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-200 bg-white/90 backdrop-blur shadow-sm">
|
||||||
|
<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-slate-900 transition hover:text-slate-600 sm:text-xl">
|
||||||
|
TableJerseys
|
||||||
|
</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-700 sm:inline">{{ backendUser?.email || user.email }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold uppercase text-white transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
@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-200 bg-white p-3 shadow-xl 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-900">{{ displayName }}</p>
|
||||||
|
<p class="px-3 text-xs text-slate-600">{{ backendUser?.email || user.email }}</p>
|
||||||
|
<div class="my-3 h-px bg-slate-200"></div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/profile"
|
||||||
|
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||||
|
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-700 transition hover:bg-slate-100"
|
||||||
|
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-600 transition hover:bg-rose-50"
|
||||||
|
@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-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||||
|
>
|
||||||
|
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-200"></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-white p-6 shadow-xl">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold text-slate-900">Sign In</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700"
|
||||||
|
>Email</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-white transition hover:bg-white hover:text-slate-900 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-200"></div>
|
||||||
|
<span class="px-3 text-sm text-slate-500">or</span>
|
||||||
|
<div class="flex-1 border-t border-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleGoogleLogin"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="loginError || error" class="mt-4 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||||
|
{{ loginError || error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm text-slate-600">
|
||||||
|
Need an account?
|
||||||
|
<NuxtLink
|
||||||
|
to="/register"
|
||||||
|
class="font-semibold text-slate-900 hover:text-slate-700"
|
||||||
|
@click="isOpen = false"
|
||||||
|
>
|
||||||
|
Create one instead
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="isOpen = false"
|
||||||
|
class="mt-4 text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
151
app/components/designer/DesignerCanvas.vue
Normal file
151
app/components/designer/DesignerCanvas.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type { Canvas as FabricCanvas } from "fabric";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
size: number;
|
||||||
|
registerCanvas: (payload: {
|
||||||
|
canvas: FabricCanvas;
|
||||||
|
fabric: typeof import("fabric");
|
||||||
|
}) => void;
|
||||||
|
unregisterCanvas: () => void;
|
||||||
|
backgroundColor: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const containerElement = ref<HTMLDivElement | null>(null);
|
||||||
|
const isReady = ref(false);
|
||||||
|
|
||||||
|
let fabricCanvas: FabricCanvas | null = null;
|
||||||
|
let fabricModule: typeof import("fabric") | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const syncCanvasDomStyles = () => {
|
||||||
|
if (!fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvases = [fabricCanvas.lowerCanvasEl, fabricCanvas.upperCanvasEl];
|
||||||
|
canvases.forEach((element) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.classList.add("rounded-full");
|
||||||
|
element.style.borderRadius = "9999px";
|
||||||
|
element.style.backgroundColor = "transparent";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCssDimensions = (dimension?: number) => {
|
||||||
|
if (!fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetSize =
|
||||||
|
dimension ??
|
||||||
|
containerElement.value?.clientWidth ??
|
||||||
|
containerElement.value?.clientHeight ??
|
||||||
|
null;
|
||||||
|
if (!targetSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fabricCanvas.setDimensions(
|
||||||
|
{
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
},
|
||||||
|
{ cssOnly: true }
|
||||||
|
);
|
||||||
|
fabricCanvas.calcOffset();
|
||||||
|
fabricCanvas.requestRenderAll();
|
||||||
|
syncCanvasDomStyles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeContainer = () => {
|
||||||
|
if (!containerElement.value || !fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dimension = Math.min(entry.contentRect.width, entry.contentRect.height);
|
||||||
|
updateCssDimensions(dimension);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerElement.value);
|
||||||
|
updateCssDimensions();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvas = async () => {
|
||||||
|
if (typeof window === "undefined" || !canvasElement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricModule = await import("fabric");
|
||||||
|
|
||||||
|
const { Canvas } = fabricModule;
|
||||||
|
fabricCanvas = new Canvas(canvasElement.value, {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
selection: true,
|
||||||
|
preserveObjectStacking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fabricCanvas.setDimensions({ width: props.size, height: props.size });
|
||||||
|
|
||||||
|
props.registerCanvas({ canvas: fabricCanvas, fabric: fabricModule });
|
||||||
|
observeContainer();
|
||||||
|
syncCanvasDomStyles();
|
||||||
|
isReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.unregisterCanvas();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
fabricCanvas = null;
|
||||||
|
fabricModule = null;
|
||||||
|
isReady.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.size,
|
||||||
|
(next, prev) => {
|
||||||
|
if (!isReady.value || next === prev || !fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fabricCanvas.setDimensions({ width: next, height: next });
|
||||||
|
updateCssDimensions();
|
||||||
|
fabricCanvas.renderAll();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative mx-auto flex max-w-[min(720px,100%)] items-center justify-center overflow-hidden rounded-3xl border border-slate-700/60 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/40"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-square w-full">
|
||||||
|
<div class="absolute inset-4 sm:inset-5 md:inset-6 lg:inset-8">
|
||||||
|
<div ref="containerElement" class="relative h-full w-full">
|
||||||
|
<canvas
|
||||||
|
ref="canvasElement"
|
||||||
|
class="absolute inset-0 h-full w-full rounded-full"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!isReady"
|
||||||
|
class="absolute inset-0 grid place-items-center rounded-full bg-slate-900/70"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-slate-400">Loading canvas…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
app/components/designer/DesignerPreview.vue
Normal file
36
app/components/designer/DesignerPreview.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isCheckoutPending: boolean;
|
||||||
|
checkoutPrice: number;
|
||||||
|
checkoutError: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "checkout"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleCheckout = () => emit("checkout");
|
||||||
|
|
||||||
|
const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-xl border-2 border-emerald-600 bg-emerald-600 px-6 py-4 text-base font-semibold text-white transition hover:bg-white hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="props.isCheckoutPending"
|
||||||
|
@click="handleCheckout"
|
||||||
|
>
|
||||||
|
{{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="props.checkoutError"
|
||||||
|
class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ props.checkoutError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
242
app/components/designer/DesignerToolbar.vue
Normal file
242
app/components/designer/DesignerToolbar.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onAddText: () => void;
|
||||||
|
onAddCircle: () => void;
|
||||||
|
onAddRectangle: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
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;
|
||||||
|
maxZoom: number;
|
||||||
|
onZoomChange: (zoom: number) => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onZoomReset: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "request-image"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => props.activeFill,
|
||||||
|
(next) => {
|
||||||
|
fillValue.value = next ?? "#111827";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeStroke,
|
||||||
|
(next) => {
|
||||||
|
strokeValue.value = next ?? "#3b82f6";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeBackground,
|
||||||
|
(next) => {
|
||||||
|
backgroundValue.value = next ?? "#ffffff";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.zoom,
|
||||||
|
(next) => {
|
||||||
|
zoomSliderValue.value = Math.round(next * 100);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
if (!fileInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInput.value.value = "";
|
||||||
|
fileInput.value.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || !files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [file] = files;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await props.onImportImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stylingDisabled = computed(() => !props.canStyleSelection);
|
||||||
|
|
||||||
|
const handleFillChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const value = input.value;
|
||||||
|
fillValue.value = value;
|
||||||
|
props.onFillChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStrokeChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const value = input.value;
|
||||||
|
strokeValue.value = value;
|
||||||
|
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);
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zoomSliderValue.value = value;
|
||||||
|
props.onZoomChange(value / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<!-- Add Elements Group -->
|
||||||
|
<div class="flex items-center gap-1 border-r border-slate-200 pr-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-lg text-lg font-bold text-slate-700 transition hover:bg-slate-200"
|
||||||
|
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-700 transition hover:bg-slate-200"
|
||||||
|
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-700 transition hover:bg-slate-200"
|
||||||
|
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-700 transition hover:bg-slate-200"
|
||||||
|
title="Upload Image"
|
||||||
|
@click="openFilePicker"
|
||||||
|
>
|
||||||
|
🖼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Pickers Group -->
|
||||||
|
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||||
|
<label class="flex items-center gap-1.5" title="Canvas Background">
|
||||||
|
<span class="text-xs text-slate-600">Canvas</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white 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-600">Fill</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white 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-600">Stroke</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||||
|
:disabled="stylingDisabled"
|
||||||
|
:value="strokeValue"
|
||||||
|
@input="handleStrokeChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom Controls -->
|
||||||
|
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||||
|
title="Zoom Out"
|
||||||
|
@click="props.onZoomOut"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span class="min-w-12 text-center text-xs font-medium text-slate-700">
|
||||||
|
{{ zoomLabel }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||||
|
title="Zoom In"
|
||||||
|
@click="props.onZoomIn"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
|
||||||
|
title="Reset Zoom"
|
||||||
|
@click="props.onZoomReset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear Button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto rounded border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-700 transition hover:bg-rose-100"
|
||||||
|
@click="props.onClear"
|
||||||
|
>
|
||||||
|
Clear Canvas
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Hidden File Input -->
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
68
app/components/designer/TemplatePicker.vue
Normal file
68
app/components/designer/TemplatePicker.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SlipmatTemplate } from "~/composables/useSlipmatDesigner";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
templates: SlipmatTemplate[];
|
||||||
|
selectedTemplateId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "select", templateId: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleSelect = (templateId: string) => {
|
||||||
|
emit("select", templateId);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Jersey Template</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">
|
||||||
|
Pick the size and print spec that matches this order.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-1">
|
||||||
|
<button
|
||||||
|
v-for="template in props.templates"
|
||||||
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
|
class="group rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
|
||||||
|
:class="{
|
||||||
|
'border-slate-900 bg-slate-50 shadow-md':
|
||||||
|
template.id === props.selectedTemplateId,
|
||||||
|
}"
|
||||||
|
@click="handleSelect(template.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-base font-medium text-slate-900">
|
||||||
|
{{ template.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="template.id === props.selectedTemplateId"
|
||||||
|
class="rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl class="mt-3 grid grid-cols-4 gap-x-3 gap-y-2 text-xs text-slate-700">
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Diameter</dt>
|
||||||
|
<dd>{{ template.diameterInches }}"</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Resolution</dt>
|
||||||
|
<dd>{{ template.dpi }} DPI</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Bleed</dt>
|
||||||
|
<dd>{{ template.bleedInches ?? 0 }}"</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Safe Zone</dt>
|
||||||
|
<dd>{{ template.safeZoneInches ?? 0 }}"</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
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);
|
||||||
5
app/composables/useSlipmatDesigner.ts
Normal file
5
app/composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from "vue";
|
||||||
|
|
||||||
|
// import type { fabric as FabricNamespace } from "fabric";
|
||||||
|
|
||||||
|
export * from "../../composables/useSlipmatDesigner";
|
||||||
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-white pb-16">
|
||||||
|
<AppNavbar />
|
||||||
|
|
||||||
|
<section class="mx-auto flex max-w-2xl flex-col gap-6 px-4 pt-16 text-slate-900">
|
||||||
|
<header class="space-y-4 text-center">
|
||||||
|
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
|
||||||
|
<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 text-slate-900">Payment Confirmed</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">
|
||||||
|
Thank you for purchasing your custom jersey design. We've received your payment and sent a confirmation email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!sessionId" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-700">
|
||||||
|
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 border-2 border-slate-900 bg-slate-900 px-4 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900"
|
||||||
|
@click="goToDesigner"
|
||||||
|
>
|
||||||
|
Back to Designer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Order Summary</h2>
|
||||||
|
<dl class="mt-4 space-y-2 text-sm text-slate-700">
|
||||||
|
<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" class="font-semibold text-slate-900">{{ 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" class="font-semibold text-slate-900">
|
||||||
|
{{ 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 class="font-semibold text-slate-900">{{ 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-600">
|
||||||
|
<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-300 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||||
|
@click="copySessionId"
|
||||||
|
>
|
||||||
|
{{ copyStatus === 'copied' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div v-if="transactionStatus === 'saving'" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||||
|
Recording your transaction...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="transactionStatus === 'error'" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{{ 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-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||||
|
Transaction stored safely.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{{ error.message || 'We couldn\'t load the session details. Please contact support if this persists.' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">What's next?</h2>
|
||||||
|
<ul class="mt-4 space-y-3 text-sm text-slate-700">
|
||||||
|
<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 border-2 border-emerald-600 bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-emerald-600"
|
||||||
|
@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-white pb-16 text-slate-900">
|
||||||
|
<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-slate-600">
|
||||||
|
TableJerseys Designer
|
||||||
|
</p>
|
||||||
|
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||||
|
Craft custom jerseys ready for your table.
|
||||||
|
</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-200 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<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-600">
|
||||||
|
Safe zone and bleed guides update automatically when you switch
|
||||||
|
templates. Use the toolbar to layer text, shapes, and imagery
|
||||||
|
inside the design area.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
148
app/pages/index.vue
Normal file
148
app/pages/index.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startDesigning = () => {
|
||||||
|
router.push('/designer');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative min-h-screen overflow-hidden bg-white">
|
||||||
|
<!-- Subtle Background Pattern -->
|
||||||
|
<div class="absolute inset-0 opacity-5">
|
||||||
|
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 1px 1px, rgb(156 163 175) 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-slate-900 sm:text-7xl md:text-8xl">
|
||||||
|
TableJerseys
|
||||||
|
</h1>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-2xl font-semibold text-slate-700 sm:text-3xl md:text-4xl">
|
||||||
|
Design custom jerseys for your table
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Simple preview 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">
|
||||||
|
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-lg overflow-hidden">
|
||||||
|
<!-- Rotating Table with Fitted Cover -->
|
||||||
|
<div class="animate-spin-slow relative" style="transform-style: preserve-3d;">
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Table with fitted cover -->
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Table Top -->
|
||||||
|
<div class="relative h-24 w-40 rounded-t-sm bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-xl">
|
||||||
|
<!-- Jersey design on top of cover -->
|
||||||
|
<div class="absolute inset-4 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-lg border-2 border-slate-300">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-slate-800">23</div>
|
||||||
|
<div class="text-[7px] font-semibold text-slate-600 tracking-wider">CUSTOM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fitted Cover Skirt (draping down) -->
|
||||||
|
<div class="absolute top-24 left-0 right-0 h-16 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-sm shadow-2xl">
|
||||||
|
<!-- Fabric folds/creases -->
|
||||||
|
<div class="absolute inset-0 opacity-30" style="background: repeating-linear-gradient(90deg, transparent, transparent 8px, rgba(0,0,0,0.3) 8px, rgba(0,0,0,0.3) 9px);"></div>
|
||||||
|
<!-- Shadow at bottom -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-2 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base text-slate-600 sm:text-lg">
|
||||||
|
Create professional, print-ready jersey designs in minutes
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 sm:text-base">
|
||||||
|
Perfect for sports teams, events, and table enthusiasts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/designer"
|
||||||
|
class="group relative inline-flex overflow-hidden rounded-lg border-2 border-slate-900 bg-slate-900 px-12 py-5 text-xl font-bold text-white shadow-lg transition-all hover:bg-white hover:text-slate-900 active:scale-95"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 lg:text-left">
|
||||||
|
<div class="text-3xl font-bold text-slate-900 md:text-4xl">Custom</div>
|
||||||
|
<div class="text-sm text-slate-600">Any Size</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-center lg:text-left">
|
||||||
|
<div class="text-3xl font-bold text-slate-900 md:text-4xl">300 DPI</div>
|
||||||
|
<div class="text-sm text-slate-600">Print Quality</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-center lg:text-left">
|
||||||
|
<div class="text-3xl font-bold text-slate-900 md:text-4xl">$39.99</div>
|
||||||
|
<div class="text-sm text-slate-600">Per Design</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side - Simple Image -->
|
||||||
|
<div class="relative hidden w-full lg:block lg:w-1/2">
|
||||||
|
<div class="relative mx-auto max-w-4xl">
|
||||||
|
<div class="relative mx-auto h-[300px] w-[300px] sm:h-[600px] sm:w-[600px]">
|
||||||
|
<div class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-slate-50 to-slate-100 shadow-xl overflow-hidden">
|
||||||
|
<!-- Rotating Table with Fitted Cover -->
|
||||||
|
<div class=" relative" style="transform-style: preserve-3d;">
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Table with fitted cover -->
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Table Top with jersey on cover -->
|
||||||
|
<div class="relative h-48 w-96 sm:h-64 sm:w-[500px] rounded-t-lg bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 shadow-2xl">
|
||||||
|
<!-- Jersey design on top of cover -->
|
||||||
|
<div class="absolute inset-8 sm:inset-12 flex items-center justify-center rounded-xl bg-gradient-to-br from-blue-400 via-white to-red-400 shadow-2xl border-4 border-slate-300">
|
||||||
|
<div class="absolute inset-4 sm:inset-6 flex items-center justify-center rounded-lg bg-gradient-to-br from-blue-100 via-white to-red-100 border-2 border-slate-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-5xl sm:text-7xl font-bold text-slate-800">23</div>
|
||||||
|
<div class="text-sm sm:text-xl font-semibold text-slate-600 tracking-wider">CUSTOM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Subtle highlights on cover top -->
|
||||||
|
<div class="absolute top-4 left-8 h-6 w-12 rounded-full bg-white/10 blur-lg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fitted Cover Skirt (draping down like trade show table) -->
|
||||||
|
<div class="absolute top-48 sm:top-64 left-0 right-0 h-32 sm:h-40 bg-gradient-to-b from-slate-800 via-slate-900 to-slate-950 rounded-b-lg shadow-2xl">
|
||||||
|
<!-- Fabric folds/pleats -->
|
||||||
|
<div class="absolute inset-0 opacity-40" style="background: repeating-linear-gradient(90deg, transparent, transparent 16px, rgba(0,0,0,0.4) 16px, rgba(0,0,0,0.4) 18px);"></div>
|
||||||
|
<!-- More realistic vertical folds -->
|
||||||
|
<div class="absolute inset-0 opacity-20" style="background: repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, transparent 2px, transparent 14px, rgba(0,0,0,0.3) 16px);"></div>
|
||||||
|
<!-- Shadow at bottom edge -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-4 bg-black/50 rounded-b-lg"></div>
|
||||||
|
<!-- Ground shadow -->
|
||||||
|
<div class="absolute -bottom-2 left-4 right-4 h-2 bg-black/30 blur-sm rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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-white pb-16 text-slate-900">
|
||||||
|
<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-slate-600">Account</p>
|
||||||
|
<h1 class="text-3xl font-semibold text-slate-900">Order history</h1>
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
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-200 bg-slate-50" />
|
||||||
|
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Sign in to view orders</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">
|
||||||
|
Orders are associated with your TableJerseys account. Sign in to see the designs you've purchased.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900"
|
||||||
|
@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-600">
|
||||||
|
Signed in as <span class="font-medium text-slate-900">{{ customerEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||||
|
@click="fetchOrders"
|
||||||
|
:disabled="ordersLoading"
|
||||||
|
>
|
||||||
|
{{ ordersLoading ? 'Refreshing…' : 'Refresh' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ordersError" class="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-700">
|
||||||
|
{{ ordersError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ordersLoading" class="grid gap-4">
|
||||||
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||||
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||||
|
<div class="h-24 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="orders.length === 0" class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-700 shadow-sm">
|
||||||
|
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-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">
|
||||||
|
{{ formatAmount(order) }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
{{ order.currency ? order.currency.toUpperCase() : 'USD' }} • {{ formatDate(order.createdAt || order.updatedAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="order.status" class="rounded-full border border-slate-300 bg-slate-50 px-3 py-1 text-xs uppercase tracking-[0.25em] text-slate-700">
|
||||||
|
{{ order.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 grid gap-3 text-sm text-slate-700 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-900 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-900">{{ 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-900">{{ 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-900 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-slate-900 transition hover:text-slate-700"
|
||||||
|
>
|
||||||
|
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-white pb-16 text-slate-900">
|
||||||
|
<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-slate-600">Account</p>
|
||||||
|
<h1 class="text-3xl font-semibold text-slate-900">Profile</h1>
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
View your TableJerseys 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-200 bg-slate-50" />
|
||||||
|
<div class="h-28 animate-pulse rounded-2xl border border-slate-200 bg-slate-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isAuthenticated" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">You're signed out</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">
|
||||||
|
Sign in to view your profile information and order history.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900"
|
||||||
|
@click="openLogin"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">{{ displayName }}</h2>
|
||||||
|
<p class="text-sm text-slate-600">{{ displayEmail }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<NuxtLink
|
||||||
|
to="/orders"
|
||||||
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-900 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
View order history
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-rose-300 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50"
|
||||||
|
@click="handleSignOut"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Account details</h3>
|
||||||
|
<dl class="mt-4 grid gap-4 text-sm text-slate-700 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-900 break-all">{{ field.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="backendUser" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Backend session</h3>
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
The following data is provided by the TableJerseys backend and may include additional metadata used for order fulfillment.
|
||||||
|
</p>
|
||||||
|
<pre class="mt-4 overflow-x-auto rounded-xl bg-slate-50 p-4 text-xs text-slate-700">
|
||||||
|
{{ 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-white pb-16 text-slate-900">
|
||||||
|
<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-slate-600">Create Account</p>
|
||||||
|
<h1 class="text-3xl font-semibold text-slate-900">Join TableJerseys</h1>
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
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-200 bg-white p-6 shadow-sm" @submit.prevent="handleRegister">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="email" class="block text-sm font-medium text-slate-700">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="password" class="block text-sm font-medium text-slate-700">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="6"
|
||||||
|
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
<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-700">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-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
class="w-full rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white hover:text-slate-900 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-200"></div>
|
||||||
|
<span>or</span>
|
||||||
|
<div class="flex-1 border-t border-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-60"
|
||||||
|
@click="handleGoogleRegister"
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="combinedError" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||||
|
{{ combinedError }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-slate-600">
|
||||||
|
Already have an account?
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="text-slate-900 font-semibold hover:text-slate-700"
|
||||||
|
@click.prevent="goToSignIn"
|
||||||
|
>
|
||||||
|
Sign in instead
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
841
composables/useSlipmatDesigner.ts
Normal file
841
composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from "vue";
|
||||||
|
|
||||||
|
import type * as FabricNamespace from "fabric";
|
||||||
|
type FabricModule = typeof import("fabric");
|
||||||
|
|
||||||
|
export interface SlipmatTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
diameterInches: number;
|
||||||
|
dpi: number;
|
||||||
|
bleedInches?: number;
|
||||||
|
safeZoneInches?: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISPLAY_SIZE = 720;
|
||||||
|
const PREVIEW_SIZE = 1024;
|
||||||
|
const MIN_ZOOM = 0.5;
|
||||||
|
const MAX_ZOOM = 2;
|
||||||
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
const TEMPLATE_PRESETS: SlipmatTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "lp-12",
|
||||||
|
name: "12\" LP",
|
||||||
|
diameterInches: 12,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.125,
|
||||||
|
safeZoneInches: 0.25,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ep-10",
|
||||||
|
name: "10\" EP",
|
||||||
|
diameterInches: 10,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.125,
|
||||||
|
safeZoneInches: 0.25,
|
||||||
|
backgroundColor: "#f7f7f7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single-7",
|
||||||
|
name: "7\" Single",
|
||||||
|
diameterInches: 7,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.1,
|
||||||
|
safeZoneInches: 0.2,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 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",
|
||||||
|
diameterInches: 12,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0,
|
||||||
|
safeZoneInches: 0,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSlipmatDesigner = () => {
|
||||||
|
const templates = ref<SlipmatTemplate[]>(TEMPLATE_PRESETS);
|
||||||
|
const selectedTemplate = ref<SlipmatTemplate>(FALLBACK_TEMPLATE);
|
||||||
|
|
||||||
|
const fabricApi = shallowRef<FabricModule | null>(null);
|
||||||
|
const canvas = shallowRef<FabricCanvas | null>(null);
|
||||||
|
const backgroundCircle = shallowRef<FabricCircle | null>(null);
|
||||||
|
const safeZoneCircle = shallowRef<FabricCircle | null>(null);
|
||||||
|
|
||||||
|
const previewUrl = ref<string | null>(null);
|
||||||
|
const previewBlob = shallowRef<Blob | null>(null);
|
||||||
|
const productionBlob = shallowRef<Blob | null>(null);
|
||||||
|
const productionObjectUrl = ref<string | null>(null);
|
||||||
|
const isExporting = ref(false);
|
||||||
|
const activeFillColor = ref<string | null>(null);
|
||||||
|
const activeStrokeColor = ref<string | null>(null);
|
||||||
|
const hasStyleableSelection = ref(false);
|
||||||
|
const zoomLevel = ref(1);
|
||||||
|
|
||||||
|
let previewRefreshScheduled = false;
|
||||||
|
|
||||||
|
const displaySize = computed(() => DISPLAY_SIZE);
|
||||||
|
|
||||||
|
const productionPixelSize = computed(() =>
|
||||||
|
Math.round(selectedTemplate.value.diameterInches * selectedTemplate.value.dpi)
|
||||||
|
);
|
||||||
|
|
||||||
|
const templateLabel = computed(
|
||||||
|
() =>
|
||||||
|
`${selectedTemplate.value.name} (${selectedTemplate.value.diameterInches}\" @ ${selectedTemplate.value.dpi} DPI)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomPercent = computed(() => Math.round(zoomLevel.value * 100));
|
||||||
|
|
||||||
|
const clampZoom = (value: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
|
||||||
|
|
||||||
|
const schedulePreviewRefresh = () => {
|
||||||
|
if (previewRefreshScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewRefreshScheduled = true;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
requestAnimationFrame(async () => {
|
||||||
|
previewRefreshScheduled = false;
|
||||||
|
await refreshPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maintainStaticLayerOrder = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (backgroundCircle.value) {
|
||||||
|
backgroundCircle.value.set({ radius: currentCanvas.getWidth() / 2 });
|
||||||
|
}
|
||||||
|
if (backgroundCircle.value) {
|
||||||
|
currentCanvas.sendObjectToBack(backgroundCircle.value);
|
||||||
|
}
|
||||||
|
if (safeZoneCircle.value) {
|
||||||
|
currentCanvas.bringObjectToFront(safeZoneCircle.value);
|
||||||
|
}
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStaticObject = (object: FabricObject | null) =>
|
||||||
|
object === backgroundCircle.value || object === safeZoneCircle.value;
|
||||||
|
|
||||||
|
const getSelectedObjects = (): FabricObject[] => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const activeObjects = currentCanvas.getActiveObjects() as FabricObject[];
|
||||||
|
if (activeObjects && activeObjects.length) {
|
||||||
|
return activeObjects;
|
||||||
|
}
|
||||||
|
const activeObject = currentCanvas.getActiveObject() as FabricObject | null;
|
||||||
|
return activeObject ? [activeObject] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleableSelection = (): FabricObject[] =>
|
||||||
|
getSelectedObjects().filter((object) => {
|
||||||
|
if (!object || isStaticObject(object)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = object as FabricObject & {
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
fill?: unknown;
|
||||||
|
stroke?: unknown;
|
||||||
|
};
|
||||||
|
return typeof candidate.set === "function" && ("fill" in candidate || "stroke" in candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSelectedStyleState = () => {
|
||||||
|
const styleable = getStyleableSelection();
|
||||||
|
hasStyleableSelection.value = styleable.length > 0;
|
||||||
|
if (!styleable.length) {
|
||||||
|
activeFillColor.value = null;
|
||||||
|
activeStrokeColor.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = styleable[0] as FabricObject & {
|
||||||
|
fill?: unknown;
|
||||||
|
stroke?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
activeFillColor.value =
|
||||||
|
primary && typeof primary.fill === "string" ? primary.fill : null;
|
||||||
|
activeStrokeColor.value =
|
||||||
|
primary && typeof primary.stroke === "string" ? primary.stroke : null;
|
||||||
|
|
||||||
|
canvas.value?.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canStyleSelection = computed(() => hasStyleableSelection.value);
|
||||||
|
|
||||||
|
const refreshPreview = async () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const multiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||||
|
const url = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
previewUrl.value = url;
|
||||||
|
previewBlob.value = await dataUrlToBlob(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplateToCanvas = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!currentCanvas || !fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = currentCanvas.getWidth();
|
||||||
|
const { backgroundColor, safeZoneInches = 0, diameterInches } =
|
||||||
|
selectedTemplate.value;
|
||||||
|
|
||||||
|
const bgCircle =
|
||||||
|
backgroundCircle.value ??
|
||||||
|
new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: size / 2,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bgCircle.set({ fill: backgroundColor });
|
||||||
|
bgCircle.set({ radius: size / 2 });
|
||||||
|
|
||||||
|
if (!backgroundCircle.value) {
|
||||||
|
backgroundCircle.value = bgCircle;
|
||||||
|
currentCanvas.add(bgCircle);
|
||||||
|
}
|
||||||
|
currentCanvas.sendObjectToBack(bgCircle);
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
|
||||||
|
const safeRatio = Math.max(
|
||||||
|
0,
|
||||||
|
(diameterInches - safeZoneInches * 2) / diameterInches
|
||||||
|
);
|
||||||
|
|
||||||
|
const safeCircleRadius = (size / 2) * safeRatio;
|
||||||
|
|
||||||
|
const safeCircle =
|
||||||
|
safeZoneCircle.value ??
|
||||||
|
new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: safeCircleRadius,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "rgba(0,0,0,0)",
|
||||||
|
stroke: "#4b5563",
|
||||||
|
strokeDashArray: [8, 8],
|
||||||
|
strokeWidth: 1,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
hoverCursor: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
safeCircle.set({ radius: safeCircleRadius });
|
||||||
|
|
||||||
|
if (!safeZoneCircle.value) {
|
||||||
|
safeZoneCircle.value = safeCircle;
|
||||||
|
currentCanvas.add(safeCircle);
|
||||||
|
}
|
||||||
|
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
|
||||||
|
currentCanvas.clipPath = new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: size / 2,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
absolutePositioned: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentCanvas.renderAll();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerCanvas = ({ canvas: instance, fabric }: CanvasReadyPayload) => {
|
||||||
|
fabricApi.value = fabric;
|
||||||
|
canvas.value = instance;
|
||||||
|
|
||||||
|
instance.setDimensions({ width: DISPLAY_SIZE, height: DISPLAY_SIZE });
|
||||||
|
instance.preserveObjectStacking = true;
|
||||||
|
|
||||||
|
const refreshEvents = [
|
||||||
|
"object:added",
|
||||||
|
"object:modified",
|
||||||
|
"object:removed",
|
||||||
|
"object:skewing",
|
||||||
|
"object:scaling",
|
||||||
|
"object:rotating",
|
||||||
|
"object:moving",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const handleMutation = () => {
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshEvents.forEach((eventName) => {
|
||||||
|
instance.on(eventName, handleMutation);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionEvents = [
|
||||||
|
"selection:created",
|
||||||
|
"selection:updated",
|
||||||
|
"selection:cleared",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
selectionEvents.forEach((eventName) => {
|
||||||
|
instance.on(eventName, () => updateSelectedStyleState());
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on("mouse:wheel", (opt) => {
|
||||||
|
const { e } = opt;
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const direction = delta > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||||
|
const nextZoom = zoomLevel.value + direction;
|
||||||
|
const pointer = instance.getPointer(e);
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = new fabric.Point(pointer.x, pointer.y);
|
||||||
|
setZoom(nextZoom, { point });
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTemplateToCanvas();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
setZoom(zoomLevel.value);
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unregisterCanvas = () => {
|
||||||
|
canvas.value?.dispose();
|
||||||
|
canvas.value = null;
|
||||||
|
fabricApi.value = null;
|
||||||
|
backgroundCircle.value = null;
|
||||||
|
safeZoneCircle.value = null;
|
||||||
|
activeFillColor.value = null;
|
||||||
|
activeStrokeColor.value = null;
|
||||||
|
hasStyleableSelection.value = false;
|
||||||
|
zoomLevel.value = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const centerPoint = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return { x: DISPLAY_SIZE / 2, y: DISPLAY_SIZE / 2 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: currentCanvas.getWidth() / 2,
|
||||||
|
y: currentCanvas.getHeight() / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTextbox = () => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
const textbox: FabricTextbox = new fabric.Textbox("Your Text", {
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "#111827",
|
||||||
|
fontSize: 36,
|
||||||
|
fontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif",
|
||||||
|
editable: true,
|
||||||
|
width: DISPLAY_SIZE * 0.5,
|
||||||
|
textAlign: "center",
|
||||||
|
});
|
||||||
|
currentCanvas.add(textbox);
|
||||||
|
currentCanvas.setActiveObject(textbox);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addShape = (shape: "circle" | "rect") => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
let object: FabricCircle | FabricRect;
|
||||||
|
|
||||||
|
if (shape === "circle") {
|
||||||
|
object = new fabric.Circle({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
radius: DISPLAY_SIZE * 0.18,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "rgba(59, 130, 246, 0.4)",
|
||||||
|
stroke: "#3b82f6",
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
object = new fabric.Rect({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: DISPLAY_SIZE * 0.3,
|
||||||
|
height: DISPLAY_SIZE * 0.18,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
rx: 12,
|
||||||
|
ry: 12,
|
||||||
|
fill: "rgba(16, 185, 129, 0.35)",
|
||||||
|
stroke: "#10b981",
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCanvas.add(object);
|
||||||
|
currentCanvas.setActiveObject(object);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImageFromFile = async (file: File) => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
|
||||||
|
const img = await fabric.Image.fromURL(dataUrl, {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
img.set({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: true,
|
||||||
|
clipPath: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSize = DISPLAY_SIZE * 0.8;
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
maxSize / Math.max(img.width ?? maxSize, img.height ?? maxSize)
|
||||||
|
);
|
||||||
|
img.scale(scale);
|
||||||
|
currentCanvas.add(img);
|
||||||
|
currentCanvas.setActiveObject(img);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveFillColor = (color: string) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = getStyleableSelection();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.forEach((object) => {
|
||||||
|
(object as FabricObject & { set: (key: string, value: unknown) => void }).set(
|
||||||
|
"fill",
|
||||||
|
color
|
||||||
|
);
|
||||||
|
});
|
||||||
|
activeFillColor.value = color;
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveStrokeColor = (color: string) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = getStyleableSelection();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.forEach((object) => {
|
||||||
|
const target = object as FabricObject & {
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
strokeWidth?: number;
|
||||||
|
};
|
||||||
|
target.set("stroke", color);
|
||||||
|
if (target.strokeWidth === undefined || target.strokeWidth === 0) {
|
||||||
|
target.set("strokeWidth", 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activeStrokeColor.value = color;
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoom = (value: number, options?: { point?: FabricNamespace.Point }) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!currentCanvas || !fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = clampZoom(value);
|
||||||
|
|
||||||
|
const targetPoint =
|
||||||
|
options?.point ??
|
||||||
|
new fabric.Point(
|
||||||
|
currentCanvas.getWidth() / 2,
|
||||||
|
currentCanvas.getHeight() / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
currentCanvas.zoomToPoint(targetPoint, clamped);
|
||||||
|
|
||||||
|
if (!options?.point) {
|
||||||
|
const viewport = currentCanvas.viewportTransform;
|
||||||
|
if (viewport) {
|
||||||
|
const width = currentCanvas.getWidth();
|
||||||
|
const height = currentCanvas.getHeight();
|
||||||
|
viewport[4] = (width - width * clamped) / 2;
|
||||||
|
viewport[5] = (height - height * clamped) / 2;
|
||||||
|
currentCanvas.setViewportTransform(viewport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomLevel.value = clamped;
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => setZoom(zoomLevel.value + ZOOM_STEP);
|
||||||
|
|
||||||
|
const zoomOut = () => setZoom(zoomLevel.value - ZOOM_STEP);
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (currentCanvas) {
|
||||||
|
currentCanvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
}
|
||||||
|
setZoom(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDesign = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const staticObjects = new Set<FabricObject | null>([
|
||||||
|
backgroundCircle.value,
|
||||||
|
safeZoneCircle.value,
|
||||||
|
]);
|
||||||
|
currentCanvas
|
||||||
|
.getObjects()
|
||||||
|
.filter(
|
||||||
|
(object: FabricObject | null): object is FabricObject =>
|
||||||
|
!staticObjects.has(object)
|
||||||
|
)
|
||||||
|
.forEach((object: FabricObject) => currentCanvas.remove(object));
|
||||||
|
currentCanvas.discardActiveObject();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTemplate = (templateId: string) => {
|
||||||
|
const template = templates.value.find((entry) => entry.id === templateId);
|
||||||
|
if (template) {
|
||||||
|
selectedTemplate.value = template;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isExporting.value = true;
|
||||||
|
try {
|
||||||
|
const previewMultiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||||
|
const previewDataUrl = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier: previewMultiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
const previewDataBlob = await dataUrlToBlob(previewDataUrl);
|
||||||
|
|
||||||
|
const productionSize = productionPixelSize.value;
|
||||||
|
const productionMultiplier = productionSize / currentCanvas.getWidth();
|
||||||
|
const productionDataUrl = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier: productionMultiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
const productionDataBlob = await dataUrlToBlob(productionDataUrl);
|
||||||
|
|
||||||
|
const canvasJson = currentCanvas.toJSON();
|
||||||
|
|
||||||
|
previewUrl.value = previewDataUrl;
|
||||||
|
previewBlob.value = previewDataBlob;
|
||||||
|
productionBlob.value = productionDataBlob;
|
||||||
|
|
||||||
|
if (productionObjectUrl.value) {
|
||||||
|
URL.revokeObjectURL(productionObjectUrl.value);
|
||||||
|
}
|
||||||
|
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewUrl: previewDataUrl,
|
||||||
|
previewBlob: previewDataBlob,
|
||||||
|
productionUrl: productionDataUrl,
|
||||||
|
productionBlob: productionDataBlob,
|
||||||
|
templateId: selectedTemplate.value.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
canvasJson,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPreview = async () => {
|
||||||
|
if (!previewUrl.value) {
|
||||||
|
await refreshPreview();
|
||||||
|
}
|
||||||
|
if (!previewUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerDownload(previewUrl.value, `${selectedTemplate.value.id}-preview.png`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadProduction = async () => {
|
||||||
|
if (!productionObjectUrl.value) {
|
||||||
|
const exportResult = await exportDesign();
|
||||||
|
if (!exportResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!productionObjectUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerDownload(
|
||||||
|
productionObjectUrl.value,
|
||||||
|
`${selectedTemplate.value.id}-production.png`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
selectedTemplate,
|
||||||
|
selectTemplate,
|
||||||
|
loadDesign,
|
||||||
|
displaySize,
|
||||||
|
productionPixelSize,
|
||||||
|
templateLabel,
|
||||||
|
previewUrl,
|
||||||
|
previewBlob,
|
||||||
|
productionBlob,
|
||||||
|
productionObjectUrl,
|
||||||
|
isExporting,
|
||||||
|
activeFillColor,
|
||||||
|
activeStrokeColor,
|
||||||
|
canStyleSelection,
|
||||||
|
zoomLevel,
|
||||||
|
zoomPercent,
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: MAX_ZOOM,
|
||||||
|
registerCanvas,
|
||||||
|
unregisterCanvas,
|
||||||
|
addTextbox,
|
||||||
|
addShape,
|
||||||
|
addImageFromFile,
|
||||||
|
setActiveFillColor,
|
||||||
|
setActiveStrokeColor,
|
||||||
|
setBackgroundColor,
|
||||||
|
setZoom,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
clearDesign,
|
||||||
|
exportDesign,
|
||||||
|
downloadPreview,
|
||||||
|
downloadProduction,
|
||||||
|
refreshPreview,
|
||||||
|
schedulePreviewRefresh,
|
||||||
|
applyTemplateToCanvas,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataUrlToBlob = async (dataUrl: string): Promise<Blob> => {
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = (url: string, filename: string) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
anchor.click();
|
||||||
|
};
|
||||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
tablejerseys-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: tablejerseys-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.tablejerseys-web.rule=Host(`${DOMAIN:-tablejerseys.com}`)"
|
||||||
|
- "traefik.http.routers.tablejerseys-web.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.tablejerseys-web.tls=true"
|
||||||
|
- "traefik.http.routers.tablejerseys-web.tls.certresolver=le"
|
||||||
|
- "traefik.http.services.tablejerseys-web.loadbalancer.server.port=3000"
|
||||||
|
# HTTP to HTTPS redirect
|
||||||
|
- "traefik.http.routers.tablejerseys-web-http.rule=Host(`${DOMAIN:-tablejerseys.com}`)"
|
||||||
|
- "traefik.http.routers.tablejerseys-web-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.tablejerseys-web-http.middlewares=https-redirect"
|
||||||
|
- "traefik.http.middlewares.https-redirect.redirectscheme.scheme=https"
|
||||||
|
networks:
|
||||||
|
- traefik-public
|
||||||
|
- crew-app-net
|
||||||
|
- tablejerseys-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
|
||||||
|
tablejerseys-network:
|
||||||
|
driver: bridge
|
||||||
33
nuxt.config.ts
Normal file
33
nuxt.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
css: ["./app/assets/css/main.css"],
|
||||||
|
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: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
12381
package-lock.json
generated
Normal file
12381
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "tablejerseys-web",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"fabric": "^6.0.2",
|
||||||
|
"firebase": "^12.5.0",
|
||||||
|
"nuxt": "^4.2.0",
|
||||||
|
"stripe": "^19.3.0",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
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 |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
34
public/turntable-mockup.svg
Normal file
34
public/turntable-mockup.svg
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1600" height="1200" viewBox="0 0 1600 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1600" height="1200" rx="48" fill="#0B0F1A"/>
|
||||||
|
<rect x="64" y="64" width="1472" height="1072" rx="36" fill="#111827" stroke="#1F2937" stroke-width="8"/>
|
||||||
|
<rect x="120" y="120" width="1360" height="960" rx="28" fill="#0F172A" stroke="#1E293B" stroke-width="6"/>
|
||||||
|
<circle cx="560" cy="600" r="420" fill="#05070E" stroke="#1F2937" stroke-width="18"/>
|
||||||
|
<circle cx="560" cy="600" r="360" fill="#0A101C" stroke="#0EA5E9" stroke-width="10" stroke-dasharray="6 20" opacity="0.65"/>
|
||||||
|
<circle cx="560" cy="600" r="210" fill="#020409" opacity="0.35"/>
|
||||||
|
<circle cx="560" cy="600" r="16" fill="#CBD5F5"/>
|
||||||
|
<g filter="url(#deckGlow)">
|
||||||
|
<path d="M1032 280h260c30 0 54 24 54 54v532c0 30-24 54-54 54h-260" stroke="#1E293B" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#tonearmShadow)">
|
||||||
|
<path d="M1000 360h200c24 0 44 20 44 44v80c0 24-20 44-44 44h-92l-72 240c-8 26-32 44-60 44-34 0-62-28-62-62V422c0-34 28-62 62-62Z" fill="#E2E8F0"/>
|
||||||
|
<path d="M1116 472c12 0 22 10 22 22s-10 22-22 22-22-10-22-22 10-22 22-22Z" fill="#1E293B"/>
|
||||||
|
<rect x="1120" y="600" width="24" height="120" rx="10" fill="#1E293B"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="320" y="240" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<rect x="320" y="900" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<rect x="960" y="900" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<circle cx="352" cy="760" r="36" fill="#1E293B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="deckGlow" x="980" y="260" width="409" height="680" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="12" result="blur"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="blur" mode="overlay"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="tonearmShadow" x="792" y="332" width="472" height="480" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-opacity="0.4" flood-color="#000"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
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
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user